Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492

feat(lightbox): medium-resolution preview tier (#492)
This commit is contained in:
Paul Nothaft
2026-05-14 22:44:35 +02:00
committed by GitHub
13 changed files with 578 additions and 8 deletions
@@ -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 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">
+7
View File
@@ -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 200500 KB) statt des vollen Originals (oft 512 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",
+7
View File
@@ -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 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.",
"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",
+5
View File
@@ -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;