Files
portfolio/components/gallery-carousel.tsx

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>
);
}