Repository Public
1"use client";23import Image from "next/image";4import { useCallback, useEffect, useRef, useState } from "react";5import { formatDate, getDict, type Locale } from "@/lib/i18n";6import type { Photo } from "@/lib/schemas";78/** Editorial gallery with a native <dialog> lightbox (focus trap, Escape and arrow keys handled). */9export function Gallery({ photos, locale }: { photos: Photo[]; locale: Locale }) {10 const t = getDict(locale);11 const g = t.gallery;12 const [index, setIndex] = useState<number | null>(null);13 const dialog = useRef<HTMLDialogElement>(null);14 const opener = useRef<HTMLElement | null>(null);1516 const open = (i: number, el: HTMLElement) => {17 opener.current = el;18 setIndex(i);19 dialog.current?.showModal();20 };21 const close = useCallback(() => {22 dialog.current?.close();23 }, []);24 const step = useCallback((d: number) => setIndex((i) => (i === null ? i : (i + d + photos.length) % photos.length)), [photos.length]);2526 useEffect(() => {27 const d = dialog.current;28 if (!d) return;29 const onClose = () => {30 setIndex(null);31 opener.current?.focus();32 };33 const onKey = (e: KeyboardEvent) => {34 if (e.key === "ArrowRight") step(1);35 if (e.key === "ArrowLeft") step(-1);36 };37 d.addEventListener("close", onClose);38 d.addEventListener("keydown", onKey);39 return () => {40 d.removeEventListener("close", onClose);41 d.removeEventListener("keydown", onKey);42 };43 }, [step]);4445 const current = index !== null ? photos[index] : null;46 const spans = ["md:col-span-7", "md:col-span-5 md:mt-24", "md:col-span-5", "md:col-span-7 md:-mt-16"];4748 return (49 <>50 <ul className="grid gap-10 md:grid-cols-12 md:gap-x-8 md:gap-y-16">51 {photos.map((p, i) => (52 <li key={p.id} className={`reveal ${spans[i % spans.length]}`}>53 <figure>54 <button type="button" className="photo-frame group block w-full cursor-zoom-in" onClick={(e) => open(i, e.currentTarget)} aria-label={`${g.open}: ${p.title[locale]}`}>55 <Image src={p.file} alt={p.alt[locale]} width={p.width} height={p.height} sizes="(min-width: 768px) 55vw, 100vw" placeholder="blur" blurDataURL={p.blur} className="h-auto w-full transition-transform duration-700 group-hover:scale-[1.015]" />56 </button>57 <figcaption className="mt-4 grid gap-2 md:grid-cols-[auto_1fr] md:gap-6">58 <span className="num font-mono text-[0.6875rem] tracking-[0.1em] text-accent">{String(i + 1).padStart(2, "0")}</span>59 <span>60 <span className="block text-[1.0625rem] font-medium tracking-[-0.01em]">{p.title[locale]}</span>61 <span className="mt-2 block max-w-[58ch] text-[0.9375rem] leading-relaxed text-body">{p.caption[locale]}</span>62 {p.takenAt ? <span className="label mt-3 block">{t.common.takenOn} <time dateTime={p.takenAt}>{formatDate(p.takenAt, locale)}</time></span> : null}63 </span>64 </figcaption>65 </figure>66 </li>67 ))}68 </ul>6970 <dialog ref={dialog} aria-label={current ? current.title[locale] : g.title} className="m-0 h-dvh max-h-none w-screen max-w-none bg-transparent p-0 text-fg" onClick={(e) => e.target === e.currentTarget && close()}>71 {current && index !== null ? (72 <div className="flex h-full flex-col">73 <div className="flex items-center justify-between gap-4 px-4 py-3 md:px-8">74 <p className="num font-mono text-[0.75rem] tracking-[0.08em] text-body" aria-live="polite">{g.counter(index + 1, photos.length)}</p>75 <button type="button" className="btn btn-ghost !min-h-10 !py-2 text-[0.8125rem]" onClick={close} autoFocus>{g.close}</button>76 </div>77 <div className="relative min-h-0 flex-1" onClick={close}>78 <Image key={current.id} src={current.file} alt={current.alt[locale]} fill sizes="100vw" className="object-contain" placeholder="blur" blurDataURL={current.blur} />79 </div>80 <div className="flex items-center justify-between gap-4 px-4 py-4 md:px-8">81 <button type="button" className="btn btn-ghost !min-h-10 !py-2 text-[0.8125rem]" onClick={() => step(-1)} aria-label={g.prev}>←</button>82 <p className="min-w-0 flex-1 truncate text-center text-[0.875rem] text-body">{current.title[locale]}</p>83 <button type="button" className="btn btn-ghost !min-h-10 !py-2 text-[0.8125rem]" onClick={() => step(1)} aria-label={g.next}>→</button>84 </div>85 </div>86 ) : null}87 </dialog>88 </>89 );90}91