/** * Lightbox — fullscreen image viewer with navigation and EXIF info. * * Features: * - The 1440px preview, and — where zoom is on — the full-resolution file, * fetched only when the visitor zooms (#467; gestures in hooks/useZoom.ts) * - Previous/Next navigation (arrows + swipe) * - Close (Esc, click outside, X button) * - EXIF metadata panel (fetched on demand) * - Keyboard shortcut list (`?` or `h`), deliberately unadvertised * - Real fullscreen (`f`), where the browser offers it * - Preloads adjacent previews (never the full-resolution files) */ 'use client'; import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import { useExif } from '@/hooks/useExif'; import { useSwipe } from '@/hooks/useSwipe'; import { useZoom } from '@/hooks/useZoom'; import { zoomFitsDevice } from '@/lib/zoom'; import styles from './Lightbox.module.css'; import { useProofing } from './useProofing'; import { IconHeart } from './Icons'; import { useDictionary } from './I18nProvider'; // From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a // client component cannot import. import { resolveWatermarkOpacity } from '@/lib/config/schema'; import { APERTURE_SIGN, formatCamera, formatLens } from '@/lib/exif'; import { buildPhotoPermalink } from '@/lib/photoHash'; import { trapTabKey } from '@/lib/focusTrap'; import { canonicalImageUrl } from '@/lib/imageSize'; import { nextSlideshowSpeed, type SlideshowSpeed } from '@/lib/slideshow'; import { LIGHTBOX_SHORTCUTS, lightboxActionFor, shortcutDisplayKeys, } from '@/lib/lightboxShortcuts'; export interface LightboxWatermark { enabled?: boolean; text?: string; opacity?: number; position?: 'bottom-right' | 'bottom-left' | 'center' | string; } interface LightboxProps { assets: PhotoItem[]; currentIndex: number; onClose: () => void; onNext: () => void; onPrev: () => void; watermark?: LightboxWatermark; /** * Show the EXIF ("Info") toggle. Off for editorial contexts such as journal * entries, where a technical data panel interrupts the story. */ showExifToggle?: boolean; } const COARSE_POINTER = '(pointer: coarse)'; function subscribeCoarsePointer(onChange: () => void): () => void { const query = window.matchMedia?.(COARSE_POINTER); query?.addEventListener?.('change', onChange); return () => query?.removeEventListener?.('change', onChange); } /** A touch device, where very large images are not offered for zoom. */ function useCoarsePointer(): boolean { return useSyncExternalStore( subscribeCoarsePointer, () => !!window.matchMedia?.(COARSE_POINTER)?.matches, () => false, ); } export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, watermark, showExifToggle = true, }: LightboxProps) { const t = useDictionary(); const [showExif, setShowExif] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [canFullscreen, setCanFullscreen] = useState(false); const { exifData, exifLoading, fetchExif, clearExif } = useExif(); const [imageLoaded, setImageLoaded] = useState(false); /** * 'manual' is the honest outcome when the clipboard is unavailable — which is * not a rarity here: `navigator.clipboard` is undefined outside a secure * context, and a self-hosted portfolio reached over plain http on a LAN is * exactly that. The link is then shown for the visitor to copy by hand * rather than the button appearing to do nothing. */ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'manual'>('idle'); /** * Slideshow interval in seconds, or null for off. `s` cycles through the * three speeds and back to off, so the feature needs no configuration and no * control of its own — in keeping with how the viewer treats its other keys. */ const [slideshowSeconds, setSlideshowSeconds] = useState(null); const overlayRef = useRef(null); const closeBtnRef = useRef(null); // State, not a ref: useZoom attaches its listeners when this element // appears, which is after the portal mounts. const [imageContainer, setImageContainer] = useState(null); const previewRef = useRef(null); const current = assets[currentIndex]; const coarsePointer = useCoarsePointer(); const zoomable = current.type === 'image' && !!current.zoomUrl && zoomFitsDevice(current.zoomWidth, current.zoomHeight, coarsePointer); const zoom = useZoom({ photoKey: current.id, naturalWidth: zoomable ? current.zoomWidth : undefined, imageRef: previewRef, surface: imageContainer, }); const { zoomed, reset: resetZoom, zoomIn, zoomOut } = zoom; /** The zoom control and keys are offered for this photo. */ const canZoom = zoomable && zoom.enabled; const proofing = useProofing(); const isFav = proofing && current ? proofing.isFavorite(current.id) : false; const [mounted, setMounted] = useState(false); // Mount guard — createPortal needs document.body (client-only) useEffect(() => { setMounted(true); }, []); // Auto-focus the close button when the modal mounts useEffect(() => { if (mounted) { closeBtnRef.current?.focus(); } }, [mounted]); /* * Focus stays inside the dialog while it is open, and goes back to the grid * when it closes (#696) — to the tile of the photo that was on screen last, * not necessarily the one that opened the viewer, so a keyboard visitor who * browsed ahead continues from where they are. Tiles mark themselves with * `data-lightbox-index` inside a `data-lightbox-group`; without them the * opener gets focus back. */ const indexRef = useRef(currentIndex); useEffect(() => { indexRef.current = currentIndex; }, [currentIndex]); useEffect(() => { const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const onTab = (e: KeyboardEvent) => { if (overlayRef.current) trapTabKey(overlayRef.current, e); }; document.addEventListener('keydown', onTab); return () => { document.removeEventListener('keydown', onTab); const group = opener?.closest('[data-lightbox-group]'); const tile = group?.querySelector(`[data-lightbox-index="${indexRef.current}"]`); const target = tile ?? opener; if (target?.isConnected) target.focus(); }; }, []); // Reset EXIF data when switching images; refetch if panel is open useEffect(() => { clearExif(); setImageLoaded(false); if (showExif && current) { fetchExif(current.exifUrl); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentIndex]); // When the toggle is turned on or the image changes while open, fetch const handleExifToggle = useCallback(() => { const next = !showExif; setShowExif(next); if (next && current) { fetchExif(current.exifUrl); } }, [showExif, current, fetchExif]); const toggleShortcuts = useCallback(() => { setShowShortcuts((open) => !open); }, []); /** * The deep link to the photo on screen. * * Built from `current.id` — the asset's own stable token, the same one the * grid already carries on every `PhotoItem` — rather than read back from * `location.search`, so it does not depend on the grid's query-sync effect * having run yet. This only supplies the affordance to copy it (#478); the * `?photo=` form itself is what the grid writes and restores. * * Addressed by the photo, not a position: reordering the album, or * deleting a different photo, no longer repoints an already-shared link * (#588). */ const permalink = useCallback(() => { if (typeof window === 'undefined' || !current) return ''; return buildPhotoPermalink(window.location, current.id); }, [current]); const handleCopyLink = useCallback(() => { const url = permalink(); if (!navigator.clipboard) { setCopyState('manual'); return; } navigator.clipboard.writeText(url).then( () => setCopyState('copied'), () => setCopyState('manual'), ); }, [permalink]); /** * Auto-advance, so a gallery can run unattended at an exhibition, a fair * booth or on a second screen (#473). * * `onNext` already wraps at the end of the album, so the sequence loops on * its own with nothing further to arrange. */ const cycleSlideshow = useCallback(() => { setSlideshowSeconds((current) => nextSlideshowSpeed(current)); }, []); // Paused, not stopped, while zoomed: advancing would throw away the view // someone is inspecting. Back at fit, the slideshow carries on. useEffect(() => { if (slideshowSeconds === null || zoom.zoomed) return; const timer = setInterval(onNext, slideshowSeconds * 1000); return () => clearInterval(timer); }, [slideshowSeconds, onNext, zoom.zoomed]); /* * Any deliberate move through the album stops the slideshow. Someone * reaching for an arrow has taken over; leaving the timer running would * yank the photo away from under them a second later. * * The timer above keeps calling the raw `onNext`, so it does not stop * itself. */ const manualNext = useCallback(() => { setSlideshowSeconds(null); onNext(); }, [onNext]); const manualPrev = useCallback(() => { setSlideshowSeconds(null); onPrev(); }, [onPrev]); // A confirmation must not outlive the photo it was about. useEffect(() => { setCopyState('idle'); }, [currentIndex]); useEffect(() => { if (copyState !== 'copied') return; const timer = setTimeout(() => setCopyState('idle'), 2000); return () => clearTimeout(timer); }, [copyState]); /* * Real fullscreen. The overlay already covers the viewport, but the browser's * own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly * the frame a photograph should not be shown in. * * `fullscreenEnabled` is the honest gate: iPhone Safari implements the API on * `