166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
"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 l’administration (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>
|
||
);
|
||
}
|