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
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
expect(await storage.exists(key)).toBe(true);
});
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(key).toBe('previews/preview_preview-source.jpg');
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
// Source is 800x600 and default longEdge is 1920 with
// withoutEnlargement: true → preview must NOT be upscaled.
expect(meta.width).toBe(800);
expect(meta.height).toBe(600);
}
});
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
// 800x600 → fit:'inside' inside 400×400 → 400×300.
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
}
});
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
@@ -0,0 +1,59 @@
/**
* Migration: Lightbox medium-resolution preview tier (#492).
*
* Adds:
* - photos.preview_path (nullable VARCHAR) — storage key for the
* per-photo preview JPEG; populated lazily by ensurePreviewImage
* on first lightbox open (or eagerly by the regenerate-previews
* admin endpoint). Mirrors photos.thumbnail_path / hero_path.
* - app_settings.lightbox_preview_enabled (boolean, default false)
* — opt-in toggle. Off by default because the new tier costs
* ~200500 KB per photo on disk; admins flip it on once they've
* decided the perf win is worth the storage.
*
* No backfill of existing photos here — preview generation is lazy
* by design and a separate "Regenerate previews" admin button covers
* eager backfill when an admin wants to warm the cache for an
* existing gallery.
*
* Idempotent: every step checks for existing state.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
if (!(await knex.schema.hasColumn('photos', 'preview_path'))) {
await knex.schema.alterTable('photos', (table) => {
table.string('preview_path');
});
}
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (!existing) {
await knex('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
// SQLite stores TEXT, Postgres JSONB — JSON-stringify so both
// backends round-trip a recognisable boolean shape, matching
// how other branding_* boolean settings are stored today.
setting_value: JSON.stringify(false),
setting_type: 'thumbnail',
updated_at: new Date(),
});
}
};
exports.down = async function(knex) {
if (await knex.schema.hasTable('app_settings')) {
await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.del();
}
if (await knex.schema.hasTable('photos') && await knex.schema.hasColumn('photos', 'preview_path')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('preview_path');
});
}
};
+10
View File
@@ -649,6 +649,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived semantics
// as thumbnail / hero — wipe on photo delete so we don't leak
// orphaned files into previews/ that no DB row references.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
@@ -783,6 +789,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492) — bulk delete cleanup.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
+79 -4
View File
@@ -3,7 +3,7 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format'
'thumbnail_format',
// Lightbox preview tier (#492). Boolean, default false.
'lightbox_preview_enabled'
])
.select('setting_key', 'setting_value');
@@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
// Update thumbnail settings
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format } = req.body;
const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
// Validate inputs
if (width && (width < 50 || width > 1000)) {
@@ -77,14 +79,35 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
// Lightbox preview tier (#492). Boolean — store JSON-stringified
// so the round-trip matches what migration 104 seeds.
if (typeof lightbox_preview_enabled === 'boolean') {
updates.push({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(lightbox_preview_enabled),
});
}
for (const update of updates) {
await db('app_settings')
const updated = await db('app_settings')
.where('setting_key', update.setting_key)
.update({
setting_value: update.setting_value,
updated_at: db.fn.now()
});
// Defensive insert when the row is missing — covers the case
// where lightbox_preview_enabled is being saved on an install
// that pre-dates migration 104. Existing thumbnail_* keys are
// seeded by migration 040 so the update path always wins for
// them; this only fires on the new key.
if (!updated) {
await db('app_settings').insert({
setting_key: update.setting_key,
setting_value: update.setting_value,
setting_type: update.setting_key === 'lightbox_preview_enabled' ? 'thumbnail' : 'thumbnail',
updated_at: db.fn.now(),
});
}
}
res.json({
@@ -170,6 +193,58 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
}
});
// Regenerate all preview-tier images (#492). Eager backfill counterpart
// to ensurePreviewImage's lazy generation. Mirrors the regenerate
// (thumbnails) endpoint above — same auth, same fire-and-forget shape,
// same per-photo error handling.
router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body;
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
if (eventId) query = query.where('event_id', eventId);
// Skip videos — preview tier is image-only.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
if (photos.length === 0) {
return res.json({ message: 'No image photos to regenerate previews for', count: 0 });
}
res.json({
message: `Started regenerating ${photos.length} previews`,
count: photos.length,
});
setImmediate(async () => {
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
// Force regeneration regardless of existing preview state by
// nulling the cached path so ensurePreviewImage doesn't
// short-circuit on a stale isPreviewValid check.
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
if (newPreviewPath) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
logger.error(`Error regenerating preview for photo ${photo.id}:`, error);
errorCount++;
}
}
logger.info(`Preview regeneration complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting preview regeneration:', error);
res.status(500).json({ error: 'Failed to start preview regeneration' });
}
});
// Get regeneration status
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
+132 -1
View File
@@ -13,7 +13,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, withLocalCopy } = require('../services/imageProcessor');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { getStorage } = require('../services/storage');
const fs = require('fs');
@@ -399,6 +399,31 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
fragmentation_level: req.event.fragmentation_level || 3,
overlay_protection: req.event.overlay_protection !== false
};
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
res.json({
@@ -445,6 +470,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -1337,6 +1373,101 @@ router.get('/:slug/hero/:photoId',
}
);
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
}
}
);
// Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try {
+9 -1
View File
@@ -129,7 +129,10 @@ async function archiveEvent(event) {
);
}
// Delete thumbnails for this event's photos.
// Delete derived images (thumbnails / heroes / previews / watermarks)
// for this event's photos. The originals are inside the zip; the
// derived tiers are throwaway and will be regenerated lazily on
// restore (or not at all for archived events that nobody opens).
const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) {
if (photo.thumbnail_path) {
@@ -138,6 +141,11 @@ async function archiveEvent(event) {
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived
// semantics as thumbnails / heroes — wipe on archive.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {});
+10
View File
@@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) {
}
await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath);
// Lightbox preview tier (#492). Cheap to back up — typically a few
// hundred KB per photo — and saves admins the regenerate cycle on
// a restore. Tolerated when missing (admins who never enabled the
// feature won't have the folder; scanDirectory short-circuits on
// ENOENT cleanly).
await scanDirectory(path.join(storagePath, 'previews'), files, storagePath);
// Heroes too — same logic; admins who picked a hero photo for the
// gallery header had its 1920x1080 file generated and was missed
// by the original backup walk before this addition.
await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath);
await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath);
return files;
+138
View File
@@ -30,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920;
const DEFAULT_HERO_HEIGHT = 1080;
const DEFAULT_HERO_QUALITY = 85;
// Preview tier (#492). Aspect-preserved downscale for the lightbox so
// guests don't pay the full 512 MB original on every photo open.
// Same long edge as the hero (admins are already sizing for it) and
// quality 85 — JPEG artefacts at this size are imperceptible to clients
// browsing on phones / Retina laptops, and storage cost stays modest
// (~200500 KB per photo vs originals at multi-MB).
const DEFAULT_PREVIEW_LONG_EDGE = 1920;
const DEFAULT_PREVIEW_QUALITY = 85;
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
@@ -476,6 +485,132 @@ async function ensureHeroImage(photo) {
return null;
}
/**
* Generate a lightbox preview image (#492).
*
* Aspect-preserving downscale (`fit: 'inside'`) capped at
* DEFAULT_PREVIEW_LONG_EDGE. Distinct from generateHeroImage:
* - hero 1920x1080 cover-cropped (gallery hero header banner)
* - preview 1920px long edge, aspect preserved (lightbox tile)
*
* Output to `previews/preview_<filename>` so an admin who flips the
* setting back off can wipe the folder cleanly without touching
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
if (options.regenerate) {
await storage.delete(previewRelKey).catch(() => {});
}
try {
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
const longEdge = options.longEdge || DEFAULT_PREVIEW_LONG_EDGE;
const quality = options.quality || DEFAULT_PREVIEW_QUALITY;
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false,
});
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
sharpInstance = sharpInstance.withMetadata(false);
// fit: 'inside' + withoutEnlargement keeps small originals at
// their native size (no upscaling artefacts) and shrinks larger
// ones until both dimensions fit inside longEdge×longEdge.
sharpInstance = sharpInstance.resize(longEdge, longEdge, {
withoutEnlargement: true,
fit: 'inside',
});
sharpInstance = sharpInstance.jpeg({
quality,
progressive: true,
mozjpeg: true,
});
const buffer = await sharpInstance.toBuffer();
if (!buffer || buffer.length === 0) {
throw new Error('Generated preview image is empty');
}
await storage.put(previewRelKey, buffer, { contentType: 'image/jpeg' });
logger.info(`Generated preview image for ${filename}${previewRelKey}`);
return previewRelKey;
} catch (error) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate preview image for ${filename}: ${msg}`);
await storage.delete(previewRelKey).catch(() => {});
return null;
}
}
/**
* Validate an existing preview file is non-empty + readable by Sharp.
* Mirrors isHeroValid / isThumbnailValid.
*/
async function isPreviewValid(previewPath) {
const storage = getStorage();
try {
const stat = await storage.stat(previewPath);
if (!stat || stat.size === 0) return false;
if (storage.kind() === 'local') {
const localPath = storage.resolveLocalPath(previewPath);
await sharp(localPath).metadata();
}
return true;
} catch {
return false;
}
}
/**
* Lazy-generate the preview image for a photo if missing or invalid.
* Returns the storage key or null on failure (callers fall back to
* the original URL so the lightbox never shows a broken image).
*/
async function ensurePreviewImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
let sourceKey;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (photo.preview_path) {
const ok = await isPreviewValid(photo.preview_path);
if (ok) return photo.preview_path;
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
return newPreviewPath;
}
return null;
}
/**
* Extract capture date from EXIF metadata
*/
@@ -525,6 +660,9 @@ module.exports = {
generateHeroImage,
isHeroValid,
ensureHeroImage,
generatePreviewImage,
isPreviewValid,
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
};
@@ -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;