/** * 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 styles from './Lightbox.module.css'; interface ExifData { make?: string | null; model?: string | null; lensModel?: string | null; focalLength?: number | null; fNumber?: number | null; exposureTime?: string | null; iso?: number | null; city?: string | null; country?: string | null; } interface LightboxProps { assets: PhotoItem[]; currentIndex: number; onClose: () => void; onNext: () => void; onPrev: () => void; } export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) { const [showExif, setShowExif] = useState(false); const [exifData, setExifData] = useState(null); const [exifLoading, setExifLoading] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const overlayRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); const current = assets[currentIndex]; const [mounted, setMounted] = useState(false); // Mount guard — createPortal needs document.body (client-only) useEffect(() => { setMounted(true); }, []); // Fetch EXIF data on demand const fetchExif = useCallback((url: string) => { setExifLoading(true); setExifData(null); fetch(url) .then((res) => (res.ok ? res.json() : null)) .then((data) => setExifData(data)) .catch(() => setExifData(null)) .finally(() => setExifLoading(false)); }, []); // Reset EXIF data when switching images; refetch if panel is open useEffect(() => { setExifData(null); 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 useEffect(() => { const preload = (index: number) => { if (index >= 0 && index < assets.length) { const img = new Image(); img.src = assets[index].previewUrl; } }; preload(currentIndex + 1); preload(currentIndex - 1); }, [currentIndex, assets]); // Touch/swipe support const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; touchStartY.current = e.touches[0].clientY; }, []); const handleTouchEnd = useCallback( (e: React.TouchEvent) => { const dx = e.changedTouches[0].clientX - touchStartX.current; const dy = e.changedTouches[0].clientY - touchStartY.current; // Only register horizontal swipes (not vertical scrolling) if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) { if (dx > 0) onPrev(); else onNext(); } }, [onNext, onPrev], ); // 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 */}
{/* eslint-disable-next-line @next/next/no-img-element */} setImageLoaded(true)} onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)} />
{/* Next button */} {/* Counter */}
{currentIndex + 1} / {assets.length}
{/* EXIF toggle */} {/* EXIF panel */} {showExif && (
{exifLoading ? (
Loading...
) : exifData ? ( <> {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); }