169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
|
||
const [open, setOpen] = useState<number | null>(null);
|
||
const touchStartX = useRef<number | null>(null);
|
||
const thumbsRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Keyboard + scroll lock
|
||
useEffect(() => {
|
||
if (open === null) return;
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") setOpen(null);
|
||
if (e.key === "ArrowRight") setOpen((i) => (i === null ? null : (i + 1) % images.length));
|
||
if (e.key === "ArrowLeft") setOpen((i) => (i === null ? null : (i - 1 + images.length) % images.length));
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => {
|
||
window.removeEventListener("keydown", onKey);
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [open, images.length]);
|
||
|
||
// Scroll active thumbnail into view
|
||
useEffect(() => {
|
||
if (open === null || !thumbsRef.current) return;
|
||
const btn = thumbsRef.current.children[open] as HTMLElement | undefined;
|
||
btn?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
|
||
}, [open]);
|
||
|
||
if (images.length === 0) return null;
|
||
const [hero, ...rest] = images;
|
||
|
||
const go = (dir: 1 | -1) =>
|
||
setOpen((i) => (i === null ? null : (i + dir + images.length) % images.length));
|
||
|
||
return (
|
||
<>
|
||
{/* Grid */}
|
||
<div className="grid md:grid-cols-5 gap-2 md:gap-3">
|
||
<button
|
||
onClick={() => setOpen(0)}
|
||
className="md:col-span-3 relative aspect-[4/3] overflow-hidden rounded-sm group"
|
||
>
|
||
<Image
|
||
src={hero}
|
||
alt={`${alt} — Ansicht 1`}
|
||
fill
|
||
priority
|
||
sizes="(max-width: 768px) 100vw, 60vw"
|
||
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||
<span className="bg-ink/70 text-parchment text-xs px-3 py-1.5 rounded-full backdrop-blur-sm">
|
||
Alle {images.length} Fotos
|
||
</span>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="md:col-span-2 grid grid-cols-2 gap-2 md:gap-3">
|
||
{rest.slice(0, 4).map((src, idx) => (
|
||
<button
|
||
key={src}
|
||
onClick={() => setOpen(idx + 1)}
|
||
className="relative aspect-[4/3] overflow-hidden rounded-sm group"
|
||
>
|
||
<Image
|
||
src={src}
|
||
alt={`${alt} — Ansicht ${idx + 2}`}
|
||
fill
|
||
sizes="(max-width: 768px) 50vw, 25vw"
|
||
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||
/>
|
||
{idx === 3 && images.length > 5 && (
|
||
<div className="absolute inset-0 bg-ink/55 text-parchment flex items-center justify-center text-sm font-medium">
|
||
+{images.length - 5}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lightbox */}
|
||
{open !== null && (
|
||
<div
|
||
className="fixed inset-0 z-50 bg-ink/95 flex flex-col"
|
||
onClick={() => setOpen(null)}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }}
|
||
onTouchEnd={(e) => {
|
||
if (touchStartX.current === null) return;
|
||
const dx = e.changedTouches[0].clientX - (touchStartX.current as number);
|
||
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
|
||
touchStartX.current = null;
|
||
}}
|
||
>
|
||
{/* Top bar */}
|
||
<div className="shrink-0 flex items-center justify-between px-5 py-4" onClick={(e) => e.stopPropagation()}>
|
||
<span className="text-parchment/50 text-sm tabular-nums">{open + 1} / {images.length}</span>
|
||
<button
|
||
onClick={() => setOpen(null)}
|
||
className="text-parchment/70 hover:text-parchment transition-colors text-xl leading-none px-2 py-1"
|
||
aria-label="Galerie schließen"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Image */}
|
||
<div
|
||
className="flex-1 flex items-center justify-center relative px-14 md:px-20 min-h-0"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); go(-1); }}
|
||
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
|
||
aria-label="Vorheriges Bild"
|
||
>‹</button>
|
||
|
||
<div className="relative w-full max-w-5xl aspect-[4/3]">
|
||
<Image
|
||
key={open}
|
||
src={images[open]}
|
||
alt={`${alt} — Ansicht ${open + 1}`}
|
||
fill
|
||
sizes="90vw"
|
||
className="object-contain"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); go(1); }}
|
||
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
|
||
aria-label="Nächstes Bild"
|
||
>›</button>
|
||
</div>
|
||
|
||
{/* Thumbnail strip */}
|
||
<div
|
||
className="shrink-0 py-3 px-4"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div ref={thumbsRef} className="flex gap-2 overflow-x-auto" style={{ scrollbarWidth: "none" }}>
|
||
{images.map((src, idx) => (
|
||
<button
|
||
key={src}
|
||
onClick={() => setOpen(idx)}
|
||
className={`relative shrink-0 w-14 h-10 rounded overflow-hidden transition-all duration-200 ${
|
||
idx === open ? "ring-2 ring-parchment opacity-100" : "opacity-35 hover:opacity-65"
|
||
}`}
|
||
>
|
||
<Image src={src} alt="" fill sizes="56px" className="object-cover" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|