185 lines
7.9 KiB
TypeScript
185 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
type CardItem = {
|
|
id: number;
|
|
region: string;
|
|
name: string;
|
|
address: string;
|
|
image: string;
|
|
};
|
|
|
|
interface HorizontalCardScrollerProps {
|
|
items: CardItem[];
|
|
}
|
|
|
|
export default function HorizontalCardScroller({ items }: HorizontalCardScrollerProps) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
const [thumbWidth, setThumbWidth] = useState<number>(60);
|
|
const [thumbLeft, setThumbLeft] = useState<number>(0);
|
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
const dragOffsetRef = useRef<number>(0);
|
|
|
|
const CARD_WIDTH = 384;
|
|
const CARD_GAP = 16; // Tailwind gap-4
|
|
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
|
useEffect(() => {
|
|
console.log(items);
|
|
}, [items]);
|
|
|
|
const updateThumb = useCallback(() => {
|
|
const scroller = scrollRef.current;
|
|
const track = trackRef.current;
|
|
if (!scroller || !track) return;
|
|
|
|
const { scrollWidth, clientWidth, scrollLeft } = scroller;
|
|
const trackWidth = track.clientWidth;
|
|
if (scrollWidth <= 0 || trackWidth <= 0) return;
|
|
|
|
const ratioVisible = Math.max(0, Math.min(1, clientWidth / scrollWidth));
|
|
const newThumbWidth = Math.max(40, Math.round(trackWidth * ratioVisible));
|
|
const maxThumbLeft = Math.max(0, trackWidth - newThumbWidth);
|
|
const ratioPosition = scrollWidth === clientWidth ? 0 : scrollLeft / (scrollWidth - clientWidth);
|
|
const newThumbLeft = Math.round(maxThumbLeft * ratioPosition);
|
|
|
|
setThumbWidth(newThumbWidth);
|
|
setThumbLeft(newThumbLeft);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
updateThumb();
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => updateThumb();
|
|
const onResize = () => updateThumb();
|
|
el.addEventListener("scroll", onScroll);
|
|
window.addEventListener("resize", onResize);
|
|
return () => {
|
|
el.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("resize", onResize);
|
|
};
|
|
}, [updateThumb]);
|
|
|
|
useEffect(() => {
|
|
if (!isDragging) return;
|
|
const onMove = (e: MouseEvent) => {
|
|
const el = scrollRef.current;
|
|
const track = trackRef.current;
|
|
if (!el || !track) return;
|
|
const rect = track.getBoundingClientRect();
|
|
let x = e.clientX - rect.left - dragOffsetRef.current;
|
|
x = Math.max(0, Math.min(x, rect.width - thumbWidth));
|
|
setThumbLeft(x);
|
|
const ratio = rect.width === thumbWidth ? 0 : x / (rect.width - thumbWidth);
|
|
const targetScrollLeft = ratio * (el.scrollWidth - el.clientWidth);
|
|
el.scrollLeft = targetScrollLeft;
|
|
};
|
|
const onUp = () => setIsDragging(false);
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
return () => {
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
};
|
|
}, [isDragging, thumbWidth]);
|
|
|
|
const handleThumbMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const rect = trackRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
dragOffsetRef.current = e.clientX - rect.left - thumbLeft;
|
|
setIsDragging(true);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const scrollByStep = (direction: 1 | -1) => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollBy({ left: direction * SCROLL_STEP, behavior: "smooth" });
|
|
};
|
|
|
|
return (
|
|
<div className="relative h-[400px]">
|
|
<div ref={scrollRef} className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
|
|
<div className="flex h-full items-center gap-4">
|
|
{items.map((card) => (
|
|
<article
|
|
key={card.id}
|
|
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_2px_rgba(0,0,0,0.05)] transition-shadow duration-200 hover:shadow-[0_0_2px_rgba(0,0,0,0.08),0_8px_16px_rgba(0,0,0,0.12)]"
|
|
>
|
|
<div className="grid grid-rows-[192px_116px] h-full">
|
|
{/* 상단: 사진 384x192, 상단 라운드 16, 하단 0 */}
|
|
<div className="w-full h-[192px] overflow-hidden rounded-t-[16px]">
|
|
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
|
</div>
|
|
{/* 하단: 384x116, x-패딩 32, y-패딩 16, 3행 */}
|
|
<div className="h-[116px] px-8 py-4 grid grid-rows-[26px_auto_16px]">
|
|
{/* 1행: 배지 68x26, r-20, p:6px 16px, 좌측정렬 */}
|
|
<div className="w-[68px] h-[26px] rounded-[20px] px-4 py-[6px] bg-neutral-100 text-neutral-700 text-[12px] font-medium leading-[14px] flex items-center">
|
|
진행중
|
|
</div>
|
|
{/* 2행: 업체이름 24px, 400 */}
|
|
<div className="self-center">
|
|
<p className="text-[24px] font-normal text-neutral-900 truncate">{card.name}</p>
|
|
</div>
|
|
{/* 3행: 주소, 하트, 숫자, 별, 숫자 (12px, w-300, lh-16) */}
|
|
<div className="flex items-center gap-3 text-[12px] font-light leading-4 text-neutral-600">
|
|
<span className="flex-1 truncate">{card.address}</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
{/* 하트 */}
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M12 21s-7.364-4.632-9.428-8.571C.841 9.698 2.09 6.5 5.143 6.5 7.018 6.5 8.4 7.64 9 8.75 9.6 7.64 10.982 6.5 12.857 6.5c3.053 0 4.302 3.198 2.571 5.929C19.364 16.368 12 21 12 21z" />
|
|
</svg>
|
|
<span>12</span>
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
{/* 별 */}
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M12 17.27l6.18 3.73-1.64-7.03L21.5 9.24l-7.19-.61L12 2 9.69 8.63 2.5 9.24l4.96 4.73L5.82 21z" />
|
|
</svg>
|
|
<span>4.5</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pointer-events-none absolute bottom-[-5px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-6">
|
|
<button
|
|
type="button"
|
|
aria-label="이전"
|
|
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
|
onClick={() => scrollByStep(-1)}
|
|
>
|
|
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="rotate-180">
|
|
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
<div ref={trackRef} className="pointer-events-auto relative h-1 w-[480px] rounded bg-[#EDEDED]">
|
|
<div
|
|
className="absolute top-0 h-1 rounded bg-[var(--red-50,#F94B37)]"
|
|
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
|
onMouseDown={handleThumbMouseDown}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="다음"
|
|
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
|
onClick={() => scrollByStep(1)}
|
|
>
|
|
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|