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:
Paul Nothaft
2026-05-14 22:30:39 +02:00
parent 4225cd153f
commit 61f1d13210
13 changed files with 578 additions and 8 deletions
@@ -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 200500 KB) instead of the full original (often 512 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">