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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user