import { useCallback, useEffect, useMemo } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { ChevronLeft, ChevronRight, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import type { UIImage } from "@/lib/types"; interface ImageLightboxProps { images: UIImage[]; index: number | null; onIndexChange: (index: number) => void; onOpenChange: (open: boolean) => void; } /** * Modal image viewer. Uses the Radix Dialog primitives directly so we can * fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg, * which is much too small for a photo preview). * * Implementation notes: * - `translate3d` + `will-change: transform` promote the image to a GPU * compositing layer so open/swap stays at 60 FPS on long threads. * - Adjacent images are rendered in hidden `` tags so the browser * decodes them eagerly; pressing left/right feels instant. * - Radix handles `Escape` + focus trapping; we only wire up ←/→ + Home/End. * - Respects `prefers-reduced-motion` by dropping the fade + zoom-in * keyframes via `motion-reduce:*` variants. */ export function ImageLightbox({ images, index, onIndexChange, onOpenChange, }: ImageLightboxProps) { const { t } = useTranslation(); const open = index !== null; const total = images.length; const current = index !== null ? images[index] : null; const go = useCallback( (delta: number) => { if (index === null || total <= 1) return; const next = (index + delta + total) % total; onIndexChange(next); }, [index, onIndexChange, total], ); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); } else if (e.key === "ArrowRight") { e.preventDefault(); go(1); } else if (e.key === "Home") { e.preventDefault(); onIndexChange(0); } else if (e.key === "End") { e.preventDefault(); onIndexChange(total - 1); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [go, onIndexChange, open, total]); // Neighbours we want the browser to decode eagerly. const preload = useMemo(() => { if (index === null || total <= 1) return [] as UIImage[]; const prev = images[(index - 1 + total) % total]; const next = images[(index + 1) % total]; return [prev, next].filter((i) => i && i.url); }, [images, index, total]); if (!current || !current.url) return null; const hasMany = total > 1; const counter = hasMany ? `${index! + 1} / ${total}` : null; return ( {current.name ?? t("lightbox.title")} {hasMany ? ( <> { e.stopPropagation(); go(-1); }} /> { e.stopPropagation(); go(1); }} /> {counter} > ) : null} {/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */} {preload.map((img, i) => ( ))} ); } interface NavButtonProps { side: "left" | "right"; label: string; onClick: React.MouseEventHandler; } function NavButton({ side, label, onClick }: NavButtonProps) { const Icon = side === "left" ? ChevronLeft : ChevronRight; return ( ); }