| 1 | import { useCallback, useEffect, useMemo } from "react"; |
| 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; |
| 3 | import { ChevronLeft, ChevronRight, X } from "lucide-react"; |
| 4 | import { useTranslation } from "react-i18next"; |
| 5 | |
| 6 | import { cn } from "@/lib/utils"; |
| 7 | import type { UIImage } from "@/lib/types"; |
| 8 | |
| 9 | interface ImageLightboxProps { |
| 10 | images: UIImage[]; |
| 11 | index: number | null; |
| 12 | onIndexChange: (index: number) => void; |
| 13 | onOpenChange: (open: boolean) => void; |
| 14 | } |
| 15 | |
| 16 | /** |
| 17 | * Modal image viewer. Uses the Radix Dialog primitives directly so we can |
| 18 | * fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg, |
| 19 | * which is much too small for a photo preview). |
| 20 | * |
| 21 | * Implementation notes: |
| 22 | * - `translate3d` + `will-change: transform` promote the image to a GPU |
| 23 | * compositing layer so open/swap stays at 60 FPS on long threads. |
| 24 | * - Adjacent images are rendered in hidden `<img>` tags so the browser |
| 25 | * decodes them eagerly; pressing left/right feels instant. |
| 26 | * - Radix handles `Escape` + focus trapping; we only wire up ←/→ + Home/End. |
| 27 | * - Respects `prefers-reduced-motion` by dropping the fade + zoom-in |
| 28 | * keyframes via `motion-reduce:*` variants. |
| 29 | */ |
| 30 | export function ImageLightbox({ |
| 31 | images, |
| 32 | index, |
| 33 | onIndexChange, |
| 34 | onOpenChange, |
| 35 | }: ImageLightboxProps) { |
| 36 | const { t } = useTranslation(); |
| 37 | const open = index !== null; |
| 38 | const total = images.length; |
| 39 | const current = index !== null ? images[index] : null; |
| 40 | |
| 41 | const go = useCallback( |
| 42 | (delta: number) => { |
| 43 | if (index === null || total <= 1) return; |
| 44 | const next = (index + delta + total) % total; |
| 45 | onIndexChange(next); |
| 46 | }, |
| 47 | [index, onIndexChange, total], |
| 48 | ); |
| 49 | |
| 50 | useEffect(() => { |
| 51 | if (!open) return; |
| 52 | const onKey = (e: KeyboardEvent) => { |
| 53 | if (e.key === "ArrowLeft") { |
| 54 | e.preventDefault(); |
| 55 | go(-1); |
| 56 | } else if (e.key === "ArrowRight") { |
| 57 | e.preventDefault(); |
| 58 | go(1); |
| 59 | } else if (e.key === "Home") { |
| 60 | e.preventDefault(); |
| 61 | onIndexChange(0); |
| 62 | } else if (e.key === "End") { |
| 63 | e.preventDefault(); |
| 64 | onIndexChange(total - 1); |
| 65 | } |
| 66 | }; |
| 67 | window.addEventListener("keydown", onKey); |
| 68 | return () => window.removeEventListener("keydown", onKey); |
| 69 | }, [go, onIndexChange, open, total]); |
| 70 | |
| 71 | // Neighbours we want the browser to decode eagerly. |
| 72 | const preload = useMemo(() => { |
| 73 | if (index === null || total <= 1) return [] as UIImage[]; |
| 74 | const prev = images[(index - 1 + total) % total]; |
| 75 | const next = images[(index + 1) % total]; |
| 76 | return [prev, next].filter((i) => i && i.url); |
| 77 | }, [images, index, total]); |
| 78 | |
| 79 | if (!current || !current.url) return null; |
| 80 | |
| 81 | const hasMany = total > 1; |
| 82 | const counter = hasMany ? `${index! + 1} / ${total}` : null; |
| 83 | |
| 84 | return ( |
| 85 | <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}> |
| 86 | <DialogPrimitive.Portal> |
| 87 | <DialogPrimitive.Overlay |
| 88 | className={cn( |
| 89 | "fixed inset-0 z-50 bg-black/80 backdrop-blur-sm", |
| 90 | "data-[state=open]:animate-in data-[state=closed]:animate-out", |
| 91 | "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", |
| 92 | "motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none", |
| 93 | )} |
| 94 | /> |
| 95 | <DialogPrimitive.Content |
| 96 | aria-label={current.name ?? t("lightbox.title")} |
| 97 | className={cn( |
| 98 | "fixed inset-0 z-50 flex items-center justify-center", |
| 99 | "focus:outline-none", |
| 100 | "data-[state=open]:animate-in data-[state=closed]:animate-out", |
| 101 | "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", |
| 102 | "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", |
| 103 | "motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none", |
| 104 | )} |
| 105 | > |
| 106 | <DialogPrimitive.Title className="sr-only"> |
| 107 | {current.name ?? t("lightbox.title")} |
| 108 | </DialogPrimitive.Title> |
| 109 | |
| 110 | <div |
| 111 | className="relative flex max-h-[92vh] max-w-[94vw] items-center justify-center" |
| 112 | style={{ |
| 113 | transform: "translate3d(0,0,0)", |
| 114 | willChange: "transform", |
| 115 | }} |
| 116 | > |
| 117 | <img |
| 118 | key={current.url} |
| 119 | src={current.url} |
| 120 | alt={current.name ?? ""} |
| 121 | decoding="async" |
| 122 | draggable={false} |
| 123 | className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl" |
| 124 | /> |
| 125 | </div> |
| 126 | |
| 127 | {hasMany ? ( |
| 128 | <> |
| 129 | <NavButton |
| 130 | side="left" |
| 131 | label={t("lightbox.prev")} |
| 132 | onClick={(e) => { |
| 133 | e.stopPropagation(); |
| 134 | go(-1); |
| 135 | }} |
| 136 | /> |
| 137 | <NavButton |
| 138 | side="right" |
| 139 | label={t("lightbox.next")} |
| 140 | onClick={(e) => { |
| 141 | e.stopPropagation(); |
| 142 | go(1); |
| 143 | }} |
| 144 | /> |
| 145 | <div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white/90 tabular-nums"> |
| 146 | {counter} |
| 147 | </div> |
| 148 | </> |
| 149 | ) : null} |
| 150 | |
| 151 | <DialogPrimitive.Close |
| 152 | aria-label={t("lightbox.close")} |
| 153 | className={cn( |
| 154 | "absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full", |
| 155 | "bg-black/55 text-white/90 hover:bg-black/70 hover:text-white", |
| 156 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70", |
| 157 | "transition-colors motion-reduce:transition-none", |
| 158 | )} |
| 159 | > |
| 160 | <X className="h-4 w-4" aria-hidden /> |
| 161 | </DialogPrimitive.Close> |
| 162 | |
| 163 | {/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */} |
| 164 | <div aria-hidden className="hidden"> |
| 165 | {preload.map((img, i) => ( |
| 166 | <img key={`${img.url}-${i}`} src={img.url} alt="" /> |
| 167 | ))} |
| 168 | </div> |
| 169 | </DialogPrimitive.Content> |
| 170 | </DialogPrimitive.Portal> |
| 171 | </DialogPrimitive.Root> |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | interface NavButtonProps { |
| 176 | side: "left" | "right"; |
| 177 | label: string; |
| 178 | onClick: React.MouseEventHandler<HTMLButtonElement>; |
| 179 | } |
| 180 | |
| 181 | function NavButton({ side, label, onClick }: NavButtonProps) { |
| 182 | const Icon = side === "left" ? ChevronLeft : ChevronRight; |
| 183 | return ( |
| 184 | <button |
| 185 | type="button" |
| 186 | onClick={onClick} |
| 187 | aria-label={label} |
| 188 | className={cn( |
| 189 | "absolute top-1/2 -translate-y-1/2 grid h-11 w-11 place-items-center rounded-full", |
| 190 | "bg-black/55 text-white/90 hover:bg-black/70 hover:text-white", |
| 191 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70", |
| 192 | "transition-colors motion-reduce:transition-none", |
| 193 | side === "left" ? "left-4" : "right-4", |
| 194 | )} |
| 195 | > |
| 196 | <Icon className="h-5 w-5" aria-hidden /> |
| 197 | </button> |
| 198 | ); |
| 199 | } |
| 200 |