feat(lightbox): surface original camera filenames (#508)

Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.

Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.

Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
  resolved toggle as `event.use_original_filenames` so the client can
  decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
  back to the storage filename only for pre-migration-062 uploads) in
  a muted line under the position counter, truncated to keep the
  toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
  rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
  added the Captions plugin and a `title` field on the slides so the
  same name appears as a caption when the toggle is on.

The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
This commit is contained in:
Paul Nothaft
2026-05-17 00:35:16 +02:00
parent 38343e62de
commit 33de294d57
8 changed files with 77 additions and 8 deletions
+13
View File
@@ -430,6 +430,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
res.json({
@@ -458,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
...protectionSettings
},
categories: categories,
@@ -472,6 +482,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
return {
id: photo.id,
filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl,
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
@@ -143,6 +143,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// #508 — surface original camera filenames in the lightbox when the
// admin has flipped the same toggle that drives original-name downloads.
const showOriginalFilename = data?.event?.use_original_filenames === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
@@ -677,6 +680,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
showOriginalFilename={showOriginalFilename}
/>
{/* Upload Modal for full-page layouts */}
@@ -918,6 +922,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
showOriginalFilename={showOriginalFilename}
/>
</div>
@@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). When true, the
// lightbox bottom toolbar surfaces each photo's original camera name.
showOriginalFilename?: boolean;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
welcomeMessage,
onLogout,
isClient = false,
onToggleVisibility
onToggleVisibility,
showOriginalFilename = false,
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
onLogout,
isClient,
onToggleVisibility,
showOriginalFilename,
};
// Determine if we should show hero header (decoupled from layout)
@@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
enableDevtoolsProtection={enableDevtoolsProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
</>
@@ -24,6 +24,11 @@ interface PhotoLightboxProps {
onFeedbackChange?: () => void;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
// When true, surface each photo's original camera filename in the
// bottom toolbar — useful for photographers matching guest selections
// back to source files (#508). Tied to the admin-side toggle that
// also drives original-filename downloads (#493).
showOriginalFilename?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onFeedbackChange,
disableRightClick = false,
enableDevtoolsProtection = false,
showOriginalFilename = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -593,10 +599,23 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}}
>
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
<div className="text-white">
<div className="text-white min-w-0">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
{/* #508 — original camera filename next to the counter when
the admin has flipped the matching toggle. Falls back to
the storage filename only if `original_filename` is null
(pre-migration-062 uploads). truncate + max-w keep long
names from pushing the action row to another line. */}
{showOriginalFilename && (currentPhoto.original_filename || currentPhoto.filename) && (
<p
className="text-xs opacity-60 truncate max-w-[14rem] sm:max-w-md mt-0.5"
title={currentPhoto.original_filename || currentPhoto.filename}
>
{currentPhoto.original_filename || currentPhoto.filename}
</p>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
@@ -36,6 +36,9 @@ export interface BaseGalleryLayoutProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). Forwarded to the
// lightbox by layouts that mount their own (story/premium).
showOriginalFilename?: boolean;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -6,8 +6,10 @@ import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
import Download from 'yet-another-react-lightbox/plugins/download';
import Captions from 'yet-another-react-lightbox/plugins/captions';
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css';
import 'yet-another-react-lightbox/plugins/captions.css';
import { motion, AnimatePresence } from 'framer-motion';
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -185,7 +187,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
void _onPhotoClick;
@@ -233,16 +236,20 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}));
}, [filteredPhotos]);
// Lightbox slides
// Lightbox slides. `title` powers the Captions plugin — only emitted
// when the admin has flipped the original-filenames toggle (#508).
const slides = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.url,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
download: allowDownloads ? photo.url : undefined
download: allowDownloads ? photo.url : undefined,
title: showOriginalFilename
? (photo.original_filename || photo.filename)
: undefined,
}));
}, [filteredPhotos, allowDownloads]);
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
@@ -537,7 +544,13 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
close={() => setLightboxIndex(-1)}
index={lightboxIndex}
slides={slides}
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
plugins={[
Thumbnails,
Zoom,
Fullscreen,
...(allowDownloads ? [Download] : []),
...(showOriginalFilename ? [Captions] : []),
]}
animation={{ fade: 300, swipe: 250 }}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
@@ -58,7 +58,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
void _onPhotoClick;
@@ -379,6 +380,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
+8
View File
@@ -88,6 +88,10 @@ export interface GalleryInfo {
export interface Photo {
id: number;
filename: string;
// Original camera filename (e.g. DSC_1234.jpg) — populated for uploads
// post migration 062. Null for legacy rows. Surfaced in the lightbox
// when the admin toggles `use_original_filenames` on (#508).
original_filename?: string | null;
url: string;
thumbnail_url?: string;
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
@@ -168,6 +172,10 @@ export interface GalleryData {
hero_image_anchor?: string;
// Default photo sort order
default_photo_sort?: string;
// Mirror of admin's `general_use_original_filenames_for_downloads`.
// When true, the lightbox surfaces each photo's `original_filename`
// alongside the position counter (#508).
use_original_filenames?: boolean;
};
categories?: PhotoCategory[];
photos: Photo[];