SPB Git

Repository Public

Gallery.tsx 4.5 KB 91 lines tsx

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
Files main
Menu

Theme