/** * 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'; 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); 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); }, []); /* * 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 * `