Files
gaufrementbon/src/components/site/GalleryCarousel.tsx
T
gpatruno e45e9f154e update
2026-06-11 17:30:13 +02:00

166 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { GalleryImage } from "@/generated/prisma/client";
import { WaffleMenuImage } from "./WaffleMenuImage";
const INTERVAL_MS = 5000;
const MAX_VISIBLE = 3;
/** gap-3 = 0.75rem — garder aligné avec les classes Tailwind */
const GAP_PX = 12;
const SLIDE_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)";
type Props = {
images: GalleryImage[];
};
/** Nombre de positions « première image à gauche » : 0 … len-3 → len-2 positions */
function positionCount(len: number) {
return Math.max(0, len - 2);
}
export function GalleryCarousel({ images }: Props) {
const viewportRef = useRef<HTMLDivElement>(null);
const [startIndex, setStartIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [metrics, setMetrics] = useState({ itemW: 0, step: 0 });
const len = images.length;
const positions = positionCount(len);
useLayoutEffect(() => {
const el = viewportRef.current;
if (!el) return;
function measure() {
const node = viewportRef.current;
if (!node) return;
const w = node.clientWidth;
if (w <= 0) return;
const itemW = (w - 2 * GAP_PX) / MAX_VISIBLE;
const step = itemW + GAP_PX;
setMetrics((m) =>
m.itemW === itemW && m.step === step ? m : { itemW, step }
);
}
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const advance = useCallback(() => {
if (len <= MAX_VISIBLE || positions <= 1) return;
setStartIndex((i) => (i + 1) % positions);
}, [len, positions]);
useEffect(() => {
if (len <= MAX_VISIBLE || paused || positions <= 1) return undefined;
const t = window.setInterval(advance, INTERVAL_MS);
return () => window.clearInterval(t);
}, [len, paused, positions, advance]);
useEffect(() => {
if (startIndex >= positions) setStartIndex(0);
}, [startIndex, positions]);
function goToPosition(i: number) {
if (i < 0 || i >= positions || i === startIndex) return;
setStartIndex(i);
}
if (len === 0) {
return (
<p className="rounded-3xl border border-dashed border-chocolat/20 bg-cream/50 px-6 py-12 text-center text-muted">
Photos à venir ajoutez-en depuis ladministration (section Galerie).
</p>
);
}
if (len <= MAX_VISIBLE) {
const gridClass =
len === 1
? "mx-auto grid max-w-md grid-cols-1 gap-3 sm:gap-4"
: len === 2
? "mx-auto grid max-w-2xl grid-cols-2 gap-3 sm:gap-4"
: "grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4";
return (
<div className={gridClass}>
{images.map((img) => (
<figure
key={img.id}
className="group relative aspect-square overflow-hidden rounded-3xl shadow-[0_10px_40px_-10px_rgba(61,43,31,0.2)]"
>
<div className="relative h-full w-full origin-center transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.035]">
<WaffleMenuImage src={img.imageUrl} alt={img.alt} />
</div>
</figure>
))}
</div>
);
}
const translateX = metrics.step > 0 ? -startIndex * metrics.step : 0;
return (
<div
className="relative"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div
ref={viewportRef}
className="overflow-hidden rounded-3xl"
>
<div
className="flex flex-row flex-nowrap gap-3 will-change-transform"
style={{
transform: `translateX(${translateX}px)`,
transition: `transform 650ms ${SLIDE_EASE}`,
}}
>
{images.map((img) => (
<div
key={img.id}
className="shrink-0"
style={
metrics.itemW > 0
? { width: metrics.itemW, minWidth: metrics.itemW }
: { width: "calc((100% - 24px) / 3)", minWidth: "calc((100% - 24px) / 3)" }
}
>
<figure className="group relative aspect-square overflow-hidden rounded-3xl shadow-[0_10px_40px_-10px_rgba(61,43,31,0.2)]">
<div className="relative h-full w-full origin-center transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.035]">
<WaffleMenuImage src={img.imageUrl} alt={img.alt} />
</div>
</figure>
</div>
))}
</div>
</div>
{positions > 1 ? (
<div className="mt-6 flex flex-wrap justify-center gap-1.5">
{Array.from({ length: positions }).map((_, i) => (
<button
key={`dot-${i}`}
type="button"
aria-label={`Position ${i + 1} sur ${positions}`}
aria-current={i === startIndex}
onClick={() => goToPosition(i)}
className={`h-2 rounded-full transition-all duration-500 ease-out ${
i === startIndex
? "w-8 bg-gaufre"
: "w-2 bg-chocolat/30 hover:bg-chocolat/50"
}`}
/>
))}
</div>
) : null}
</div>
);
}