feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
This commit is contained in:
@@ -719,7 +719,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
src={photo.url}
|
||||
// Prefer the lightbox preview tier when the admin
|
||||
// opted in (#492). Falls back to `url` (the
|
||||
// original) when preview_url is null — happens
|
||||
// when the toggle is off, when the photo is a
|
||||
// video, or briefly while lazy generation runs.
|
||||
src={photo.preview_url || photo.url}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none pointer-events-none"
|
||||
@@ -742,7 +747,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.url}
|
||||
// Same preview-prefer-with-fallback logic as the
|
||||
// off-screen tile above (#492).
|
||||
src={photo.preview_url || photo.url}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
|
||||
@@ -12,6 +12,8 @@ interface ThumbnailSettings {
|
||||
quality: number;
|
||||
fit: string;
|
||||
format: string;
|
||||
// Lightbox preview tier (#492). Off by default; admin opts in.
|
||||
lightbox_preview_enabled: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: ThumbnailSettings = {
|
||||
@@ -20,8 +22,20 @@ const defaultSettings: ThumbnailSettings = {
|
||||
quality: 85,
|
||||
fit: 'cover',
|
||||
format: 'jpeg',
|
||||
lightbox_preview_enabled: false,
|
||||
};
|
||||
|
||||
// Backend returns the lightbox toggle as a JSON-stringified boolean
|
||||
// per migration 104. Tolerate raw boolean / "true" / "false" / "1" /
|
||||
// "0" coming back so the form mirrors whatever shape lands.
|
||||
function parseLightboxFlag(raw: unknown): boolean {
|
||||
if (raw === true || raw === 1) return true;
|
||||
if (typeof raw !== 'string') return false;
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === 'true' || trimmed === '"true"' || trimmed === '1') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface FetchedSettings {
|
||||
settings: Record<string, { value: string; description: string }>;
|
||||
fitOptions: Array<'cover' | 'contain' | 'fill' | 'inside' | 'outside'>;
|
||||
@@ -51,6 +65,7 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
|
||||
});
|
||||
}
|
||||
}, [fetchedData]);
|
||||
@@ -83,6 +98,23 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Lightbox preview tier (#492). Eager regeneration counterpart to
|
||||
// ensurePreviewImage's lazy on-first-open generation. Useful after
|
||||
// flipping the toggle on so guests don't pay the lazy-cost on the
|
||||
// very first lightbox open per gallery.
|
||||
const regeneratePreviewsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post('/admin/thumbnails/regenerate-previews');
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || t('settings.thumbnails.previewsRegenerateStarted', 'Lightbox preview regeneration started'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.thumbnails.previewsRegenerateError', 'Failed to start preview regeneration'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = <K extends keyof ThumbnailSettings>(
|
||||
key: K,
|
||||
value: ThumbnailSettings[K]
|
||||
@@ -104,6 +136,7 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
|
||||
});
|
||||
setIsDirty(false);
|
||||
}
|
||||
@@ -255,6 +288,51 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Lightbox preview tier (#492). Independent opt-in from the
|
||||
thumbnail size/quality settings above — costs disk but
|
||||
dramatically speeds up lightbox open on mobile / slow
|
||||
connections by serving an aspect-preserved ~1920px JPEG
|
||||
instead of the multi-megabyte original. */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Image className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.thumbnails.lightboxHelp', 'When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.')}
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={settings.lightbox_preview_enabled}
|
||||
onChange={(e) => handleChange('lightbox_preview_enabled', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.thumbnails.lightboxToggle', 'Use medium-resolution previews in the lightbox')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Eager regeneration so guests don't pay the lazy first-open
|
||||
cost. Only useful after the toggle is on; gated so admins
|
||||
don't accidentally kick off a job that won't be visible. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => regeneratePreviewsMutation.mutate()}
|
||||
isLoading={regeneratePreviewsMutation.isPending}
|
||||
disabled={!settings.lightbox_preview_enabled}
|
||||
leftIcon={regeneratePreviewsMutation.isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
|
||||
>
|
||||
{t('settings.thumbnails.regeneratePreviewsButton', 'Regenerate All Previews')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -1242,6 +1242,13 @@
|
||||
"regenerateButton": "Alle Vorschaubilder neu generieren",
|
||||
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
|
||||
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
|
||||
"lightboxTitle": "Lightbox-Vorschau-Stufe",
|
||||
"lightboxHelp": "Wenn aktiviert, lädt die Lightbox ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200–500 KB) statt des vollen Originals (oft 5–12 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Kostet pro Foto eine zusätzliche Vorschaudatei auf der Festplatte; Vorschauen werden beim ersten Öffnen erzeugt und unter /previews gespeichert.",
|
||||
"lightboxToggle": "Mittelauflösende Vorschauen in der Lightbox verwenden",
|
||||
"lightboxToggleHelp": "Standardmäßig deaktiviert. Aktivieren, sobald der gefühlte Geschwindigkeitsgewinn den zusätzlichen Speicherbedarf rechtfertigt.",
|
||||
"regeneratePreviewsButton": "Alle Vorschauen neu generieren",
|
||||
"previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet",
|
||||
"previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden",
|
||||
"saveSuccess": "Vorschaubild-Einstellungen gespeichert",
|
||||
"saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden",
|
||||
"loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden",
|
||||
|
||||
@@ -948,6 +948,13 @@
|
||||
"regenerateButton": "Regenerate All Thumbnails",
|
||||
"regenerateStarted": "Thumbnail regeneration started",
|
||||
"regenerateError": "Failed to start thumbnail regeneration",
|
||||
"lightboxTitle": "Lightbox Preview Tier",
|
||||
"lightboxHelp": "When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.",
|
||||
"lightboxToggle": "Use medium-resolution previews in the lightbox",
|
||||
"lightboxToggleHelp": "Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.",
|
||||
"regeneratePreviewsButton": "Regenerate All Previews",
|
||||
"previewsRegenerateStarted": "Lightbox preview regeneration started",
|
||||
"previewsRegenerateError": "Failed to start preview regeneration",
|
||||
"saveSuccess": "Thumbnail settings saved",
|
||||
"saveError": "Failed to save thumbnail settings",
|
||||
"loadError": "Failed to load thumbnail settings",
|
||||
|
||||
@@ -91,6 +91,11 @@ export interface Photo {
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||
// Lightbox preview URL (#492). Set only when the admin has flipped
|
||||
// lightbox_preview_enabled in Settings → Thumbnails. Aspect-preserved
|
||||
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
|
||||
// and falls back to `url` when null (off, video, or not yet generated).
|
||||
preview_url?: string | null;
|
||||
secure_url_template?: string;
|
||||
download_url_template?: string;
|
||||
requires_token?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user