diff --git a/next.config.ts b/next.config.ts index e9ffa30..cb55ff1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,25 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "c.pxhere.com", + pathname: "/images/**", + }, + { + protocol: "https", + hostname: "aromatica.co", + pathname: "/**", + }, + { + protocol: "https", + hostname: "cafe24img.poxo.com", + pathname: "/**", + }, + ], + }, }; export default nextConfig; diff --git a/public/sample.jpg b/public/sample.jpg new file mode 100644 index 0000000..b5e48e4 Binary files /dev/null and b/public/sample.jpg differ diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index 9340244..d9ed2e9 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -16,6 +16,7 @@ export function AppHeader() { const navRefs = React.useRef>({}); const panelRef = React.useRef(null); const blockRefs = React.useRef>({}); + const navItemRefs = React.useRef>({}); const [leftPositions, setLeftPositions] = React.useState>({}); const [panelHeight, setPanelHeight] = React.useState(0); const [blockWidths, setBlockWidths] = React.useState>({}); @@ -95,29 +96,15 @@ export function AppHeader() { if (!container) return; const containerRect = container.getBoundingClientRect(); const nextPositions: Record = {}; + const nextWidths: Record = {}; categories.forEach((cat) => { - const a = navRefs.current[cat.slug]; - if (a) { - const r = a.getBoundingClientRect(); - nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left); - } + const itemEl = navItemRefs.current[cat.slug]; + if (!itemEl) return; + const r = itemEl.getBoundingClientRect(); + nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left); + nextWidths[cat.slug] = r.width; // 각 네비 항목의 실제 폭을 사용 }); setLeftPositions(nextPositions); - - // 각 블록의 가로 폭 = 다음 항목의 left까지 남은 거리 - // 마지막 항목은 컨테이너 끝까지 확장되지 않도록 width를 지정하지 않음 - const nextWidths: Record = {}; - const ordered = categories - .filter((c) => typeof nextPositions[c.slug] === "number") - .sort((a, b) => (nextPositions[a.slug]! - nextPositions[b.slug]!)); - ordered.forEach((cat, idx) => { - const currentLeft = nextPositions[cat.slug]!; - if (idx < ordered.length - 1) { - const nextLeft = nextPositions[ordered[idx + 1].slug]!; - const width = Math.max(0, nextLeft - currentLeft); - nextWidths[cat.slug] = width; - } - }); setBlockWidths(nextWidths); // 패널 높이 = 블록들 중 최대 높이 @@ -166,11 +153,19 @@ export function AppHeader() { }} >
- {categories.map((cat) => ( -
setOpenSlug(cat.slug)}> + {categories.map((cat, idx) => ( +
setOpenSlug(cat.slug)} + ref={(el) => { + navItemRefs.current[cat.slug] = el; + }} + style={{ minWidth: idx === categories.length - 1 ? 120 : undefined }} + > { @@ -197,7 +192,7 @@ export function AppHeader() { onMouseEnter={() => { cancelClose(); setMegaOpen(true); }} onMouseLeave={() => { scheduleClose(); }} > -
+
{categories.map((cat) => (
{/*
{cat.name}
*/}
@@ -214,7 +209,7 @@ export function AppHeader() { ([]); - const [idx, setIdx] = useState(0); + const [activeIndex, setActiveIndex] = useState(0); + const [isHovered, setIsHovered] = useState(false); + const [progress, setProgress] = useState(0); // 0..1 + const rotationMs = 5000; + const rafIdRef = useRef(null); + const startedAtRef = useRef(0); + useEffect(() => { fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? [])); }, []); + + const numSlides = banners.length; + const canAutoPlay = numSlides > 1 && !isHovered; + + const startTicker = useCallback(() => { + if (!canAutoPlay) return; + startedAtRef.current = performance.now(); + const tick = () => { + const now = performance.now(); + const elapsed = now - startedAtRef.current; + const nextProgress = Math.min(1, elapsed / rotationMs); + setProgress(nextProgress); + if (nextProgress >= 1) { + setActiveIndex((i) => (i + 1) % Math.max(1, numSlides)); + startedAtRef.current = performance.now(); + setProgress(0); + } + rafIdRef.current = window.requestAnimationFrame(tick); + }; + rafIdRef.current = window.requestAnimationFrame(tick); + }, [canAutoPlay, numSlides, rotationMs]); + + const stopTicker = useCallback(() => { + if (rafIdRef.current) { + window.cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }, []); + useEffect(() => { - if ((banners?.length ?? 0) < 2) return; - const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000); - return () => clearInterval(t); - }, [banners]); - const slide = banners[idx]; - if (!slide) return null; - const content = ( -
- {slide.title} -

{slide.title}

-
- ); + stopTicker(); + setProgress(0); + startTicker(); + return () => stopTicker(); + }, [startTicker, stopTicker, activeIndex, isHovered, numSlides]); + + const goTo = useCallback((index: number) => { + if (numSlides === 0) return; + const next = (index + numSlides) % numSlides; + setActiveIndex(next); + setProgress(0); + startedAtRef.current = performance.now(); + }, [numSlides]); + + const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]); + const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]); + + const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]); + + if (numSlides === 0) return null; + return ( -
- {slide.linkUrl ? {content} : content} +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + aria-roledescription="carousel" + > +
+
+ {banners.map((banner) => ( +
+ {banner.linkUrl ? ( + + {banner.title} + + ) : ( + {banner.title} + )} +
+
+

{banner.title}

+
+
+ ))} +
+ + {/* Controls */} + {numSlides > 1 && ( + <> + + + + )} +
+ + {/* Progress bar + {numSlides > 1 && ( +
+
+
+ )} */} + + {/* Pagination */} + {numSlides > 1 && ( +
+ {banners.map((_, i) => ( +
+ )}
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9f9b420..1efe4bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ export default function RootLayout({
-
+
diff --git a/src/app/page.tsx b/src/app/page.tsx index 76b5a6d..3eb86f7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,8 @@ import { HeroBanner } from "@/app/components/HeroBanner"; -export default function Home({ searchParams }: { searchParams?: { sort?: "recent" | "popular" } }) { - const sort = searchParams?.sort ?? "recent"; +export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) { + const sp = await searchParams; + const sort = sp?.sort ?? "recent"; return (
{/* 히어로 섹션: 상단 대형 비주얼 영역 */}