/** * Lightbox — fullscreen image viewer with navigation and EXIF info. * * Features: * - Full-resolution image display * - 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 images */ 'use client'; import { useState, useEffect, useRef, useCallback } 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 styles from './Lightbox.module.css'; import { useProofing } from './ProofingContext'; 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 { formatCamera } from '@/lib/exif'; import { buildPhotoPermalink } from '@/lib/photoHash'; 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; } 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); const current = assets[currentIndex]; 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]); // 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 the current index rather than read back from `location.hash`, * so it does not depend on the grid's hash-sync effect having run yet. The * `#photo-N` form is the permalink the grid already writes and restores; * this only supplies the affordance to copy it (#478). * * The link is positional, as `gallery.yaml.example` documents: reordering an * album moves where a shared link lands. That is a property of the existing * permalink, not of the button. */ const permalink = useCallback(() => { if (typeof window === 'undefined') return ''; return buildPhotoPermalink(window.location, currentIndex); }, [currentIndex]); 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)); }, []); useEffect(() => { if (slideshowSeconds === null) return; const timer = setInterval(onNext, slideshowSeconds * 1000); return () => clearInterval(timer); }, [slideshowSeconds, onNext]); /* * 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 * `