Files
frontend/apps/dm/components/CaseImageSlider.tsx
T
2026-04-30 18:27:55 +09:00

158 lines
4.5 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
type Slide = { src: string; alt?: string };
type Props = {
slides: Slide[];
className?: string;
maxWidthClassName?: string;
autoPlayMs?: number;
imageHeightClassName?: string;
};
const PER_VIEW = 2;
export default function CaseImageSlider({
slides,
className = "",
maxWidthClassName = "max-w-[700px]",
autoPlayMs = 4500,
imageHeightClassName = "h-[340px]",
}: Props) {
const safeSlides = useMemo(() => slides.filter(Boolean), [slides]);
const [page, setPage] = useState(0);
const [dragging, setDragging] = useState(false);
const [isHover, setIsHover] = useState(false);
const startX = useRef(0);
const deltaX = useRef(0);
const pageCount = Math.ceil(safeSlides.length / PER_VIEW);
const canSlide = pageCount > 1;
const goTo = (nextPage: number) => {
if (!canSlide) return;
if (nextPage < 0) setPage(pageCount - 1);
else if (nextPage >= pageCount) setPage(0);
else setPage(nextPage);
};
const goNext = () => goTo(page + 1);
const goPrev = () => goTo(page - 1);
// ✅ 자동롤링 (hover 시 정지 포함)
useEffect(() => {
if (!canSlide || dragging || isHover) return;
const timer = window.setInterval(() => {
setPage((prev) => (prev + 1 >= pageCount ? 0 : prev + 1));
}, autoPlayMs);
return () => window.clearInterval(timer);
}, [canSlide, dragging, isHover, pageCount, autoPlayMs]);
// ✅ swipe
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canSlide) return;
setDragging(true);
startX.current = e.clientX;
deltaX.current = 0;
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
deltaX.current = e.clientX - startX.current;
};
const onPointerUp = () => {
if (!dragging) return;
setDragging(false);
const threshold = 50;
if (deltaX.current > threshold) goPrev();
if (deltaX.current < -threshold) goNext();
deltaX.current = 0;
};
if (!safeSlides.length) return null;
return (
<section className={className}>
<div className={`mx-auto ${maxWidthClassName}`}>
<div
className="overflow-hidden select-none touch-pan-y cursor-grab active:cursor-grabbing"
onMouseEnter={() => setIsHover(true)} // ✅ hover 시작 → 정지
onMouseLeave={() => setIsHover(false)} // ✅ hover 끝 → 재시작
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="flex transition-transform duration-500 ease-out"
style={{
transform: `translateX(-${page * 100}%)`,
}}
>
{Array.from({ length: pageCount }).map((_, pageIndex) => {
const pageSlides = safeSlides.slice(
pageIndex * PER_VIEW,
pageIndex * PER_VIEW + PER_VIEW
);
return (
<div
key={pageIndex}
className="grid min-w-full grid-cols-2 gap-3"
>
{pageSlides.map((slide, i) => (
<div
key={`${slide.src}-${i}`}
className={`relative ${imageHeightClassName} overflow-hidden rounded-[20px] bg-neutral-100`}
>
<Image
src={slide.src}
alt={slide.alt ?? `slide-${pageIndex * PER_VIEW + i + 1}`}
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 340px"
draggable={false}
/>
</div>
))}
</div>
);
})}
</div>
</div>
{/* 도트 */}
{canSlide && (
<div className="mt-5 flex items-center justify-center gap-[6px]">
{Array.from({ length: pageCount }).map((_, i) => (
<button
key={i}
type="button"
onClick={() => setPage(i)}
className={[
"h-[8px] w-[8px] rounded-full transition-all",
page === i ? "bg-[#18BCEB]" : "bg-[#D8DDE3]",
].join(" ")}
/>
))}
</div>
)}
</div>
</section>
);
}