186 lines
5.6 KiB
TypeScript
186 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
type Props = {
|
|
images: string[];
|
|
title?: string;
|
|
autoPlay?: boolean;
|
|
intervalMs?: number;
|
|
};
|
|
|
|
export default function GalleryCarousel({
|
|
images,
|
|
title = "Gallery",
|
|
autoPlay = true,
|
|
intervalMs = 4500,
|
|
}: Props) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
const [active, setActive] = useState(0);
|
|
const [paused, setPaused] = useState(false);
|
|
|
|
const safeImages = useMemo(() => images.filter(Boolean), [images]);
|
|
|
|
function clampIndex(i: number) {
|
|
const n = safeImages.length;
|
|
if (n === 0) return 0;
|
|
return (i + n) % n;
|
|
}
|
|
|
|
function scrollToIndex(i: number) {
|
|
const idx = clampIndex(i);
|
|
setActive(idx);
|
|
|
|
const track = containerRef.current;
|
|
const el = itemRefs.current[idx];
|
|
if (!track || !el) return;
|
|
|
|
const trackRect = track.getBoundingClientRect();
|
|
const elRect = el.getBoundingClientRect();
|
|
|
|
const target =
|
|
track.scrollLeft +
|
|
(elRect.left + elRect.width / 2) -
|
|
(trackRect.left + trackRect.width / 2);
|
|
|
|
track.scrollTo({ left: target, behavior: "smooth" });
|
|
}
|
|
|
|
|
|
// Auto-advance
|
|
useEffect(() => {
|
|
if (!autoPlay || paused || safeImages.length <= 1) return;
|
|
const t = setInterval(() => scrollToIndex(active + 1), intervalMs);
|
|
return () => clearInterval(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [autoPlay, paused, active, intervalMs, safeImages.length]);
|
|
|
|
// Keep active in sync when user swipes/scrolls
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el || safeImages.length <= 1) return;
|
|
|
|
let raf = 0;
|
|
|
|
const onScroll = () => {
|
|
cancelAnimationFrame(raf);
|
|
raf = requestAnimationFrame(() => {
|
|
const rect = el.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
|
|
let bestIdx = active;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
|
|
for (let i = 0; i < safeImages.length; i++) {
|
|
const item = itemRefs.current[i];
|
|
if (!item) continue;
|
|
const r = item.getBoundingClientRect();
|
|
const itemCenter = r.left + r.width / 2;
|
|
const d = Math.abs(itemCenter - centerX);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
|
|
if (bestIdx !== active) setActive(bestIdx);
|
|
});
|
|
};
|
|
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
el.removeEventListener("scroll", onScroll);
|
|
cancelAnimationFrame(raf);
|
|
};
|
|
}, [active, safeImages.length]);
|
|
|
|
if (safeImages.length === 0) return null;
|
|
|
|
return (
|
|
<section className="mt-10">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-xl font-semibold tracking-tight text-black/90">
|
|
{title}
|
|
</h2>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollToIndex(active - 1)}
|
|
className="rounded-full border border-black/10 bg-white/70 px-3 py-2 text-sm text-black/70 hover:text-black hover:border-black/25 transition"
|
|
aria-label="Previous"
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollToIndex(active + 1)}
|
|
className="rounded-full border border-black/10 bg-white/70 px-3 py-2 text-sm text-black/70 hover:text-black hover:border-black/25 transition"
|
|
aria-label="Next"
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Track */}
|
|
<div
|
|
ref={containerRef}
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
className="mt-4 flex w-full max-w-full gap-4 overflow-x-auto overflow-y-hidden overscroll-x-contain pb-3 scrollbar-hide px-2"
|
|
style={{ scrollSnapType: "x mandatory" }}
|
|
>
|
|
{safeImages.map((img, i) => (
|
|
<button
|
|
key={`${img}-${i}`}
|
|
ref={(el) => {
|
|
itemRefs.current[i] = el;
|
|
}}
|
|
type="button"
|
|
onClick={() => scrollToIndex(i)}
|
|
className={[
|
|
"relative shrink-0 rounded-3xl glass-strong specular overflow-hidden",
|
|
"min-w-[320px] md:min-w-[520px] h-[220px] md:h-[320px]",
|
|
"transition will-change-transform",
|
|
"focus:outline-none focus:ring-2 focus:ring-black/20",
|
|
i === active ? "scale-[1.00]" : "scale-[0.97] opacity-90 hover:opacity-100",
|
|
].join(" ")}
|
|
style={{ scrollSnapAlign: "center" }}
|
|
aria-label={`Select image ${i + 1}`}
|
|
>
|
|
<Image
|
|
src={img}
|
|
alt={`Gallery image ${i + 1}`}
|
|
fill
|
|
className="object-cover"
|
|
priority={i === 0}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent pointer-events-none" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Dots */}
|
|
{safeImages.length > 1 && (
|
|
<div className="mt-3 flex items-center justify-center gap-2">
|
|
{safeImages.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => scrollToIndex(i)}
|
|
className={[
|
|
"h-2.5 w-2.5 rounded-full transition",
|
|
i === active ? "bg-black/50" : "bg-black/15 hover:bg-black/30",
|
|
].join(" ")}
|
|
aria-label={`Go to slide ${i + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|