"use client"; import { useCallback, useEffect, useRef, useState } from "react"; /** * Client-side photo gallery for the public Photos page. Renders the same * card grid as before, but each tile is now a button that opens the photo in * a full-screen lightbox overlay. The overlay is a fixed, full-viewport layer * toggled by state — closes on backdrop click, on the ✕ button, or with Esc. * * Photos are pre-shaped on the server (caption strings already translated) so * this component carries no db/i18n dependency. */ export type LightboxPhoto = { id: string; url: string; alt: string; date: string; user: string; }; const EXIT_MS = 180; export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) { // Index of the photo shown in the lightbox, or null when closed. const [openIndex, setOpenIndex] = useState(null); // Kept mounted for the length of the fade-out, then removed. Previously // AnimatePresence owned that delay; with the animation in CSS the component // has to hold the overlay itself. const [closing, setClosing] = useState(false); const exitTimer = useRef | null>(null); // openIndex is numeric array index, guarded by null check const active = openIndex !== null ? photos[openIndex] : null; const close = useCallback(() => { if (exitTimer.current) return; setClosing(true); exitTimer.current = setTimeout(() => { exitTimer.current = null; setOpenIndex(null); setClosing(false); }, EXIT_MS); }, []); const open = useCallback((index: number) => { if (exitTimer.current) { clearTimeout(exitTimer.current); exitTimer.current = null; } setClosing(false); setOpenIndex(index); }, []); // Never leave a pending timer behind on unmount. useEffect( () => () => { if (exitTimer.current) clearTimeout(exitTimer.current); }, [], ); // Esc-to-close + lock background scroll while the overlay is open. useEffect(() => { if (active == null) return; function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); } document.addEventListener("keydown", onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; }; }, [active, close]); return ( <>
{photos.map((p, i) => ( ))}
{active ? (
cannot take focus to host a key handler. onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }} role="dialog" aria-modal="true" aria-label={active.alt} style={{ position: "fixed", inset: 0, zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", padding: "1.5rem", background: "rgba(0, 0, 0, 0.82)", backdropFilter: "blur(8px)", cursor: "zoom-out", }} > {/* Clicking the dialog backdrop (not the figure) closes the lightbox. */}
{active.alt}
{active.date} · {active.user}
) : null} ); }