/** * 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) * - 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'; 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 [showExif, setShowExif] = 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]); // Preload adjacent images (skip videos — they stream on demand) useEffect(() => { const preload = (index: number) => { if (index >= 0 && index < assets.length && assets[index].type !== 'video') { const img = new Image(); img.src = assets[index].previewUrl; } }; preload(currentIndex + 1); preload(currentIndex - 1); }, [currentIndex, assets]); const { handleTouchStart, handleTouchEnd } = useSwipe({ onSwipeLeft: onNext, onSwipeRight: onPrev, }); /* * Keyboard control and the scroll lock belong to the lightbox, not to whoever * opens it: PhotoGrid used to install the arrow keys itself, so every other * caller (EssayView, i.e. journal entries and photo essays) silently had no * keyboard navigation at all. */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Escape': onClose(); break; case 'ArrowRight': onNext(); break; case 'ArrowLeft': onPrev(); break; case 'i': case 'I': if (showExifToggle) handleExifToggle(); break; } }; document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]); // Click on overlay background → close const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === overlayRef.current) { onClose(); } }, [onClose], ); const lightboxJsx = (
{/* Close button */} {/* Previous button */} {/* Image or Video */}
{current.type === 'video' && current.videoUrl ? ( // eslint-disable-next-line jsx-a11y/media-has-caption
{/* Next button */} {/* Counter */}
Photo {currentIndex + 1} of {assets.length}
{/* Proofing favorite button */} {proofing && current && ( )} {/* EXIF toggle */} {showExifToggle && ( )} {/* EXIF panel */} {showExif && (
{exifLoading ? (
Loading...
) : exifData ? ( <> {exifData.description && (

{exifData.description}

)} {exifData.model && (
Camera {[exifData.make, exifData.model].filter(Boolean).join(' ')}
)} {exifData.lensModel && (
Lens {exifData.lensModel}
)} {exifData.focalLength && (
Focal Length {exifData.focalLength}mm
)} {exifData.fNumber && (
Aperture ƒ/{exifData.fNumber}
)} {exifData.exposureTime && (
Shutter {exifData.exposureTime}s
)} {exifData.iso && (
ISO {exifData.iso}
)} {(exifData.city || exifData.country) && (
Location {[exifData.city, exifData.country].filter(Boolean).join(', ')}
)} ) : (
No EXIF data available
)}
)}
); if (!mounted) return null; return createPortal(lightboxJsx, document.body); }