메인 베너너
This commit is contained in:
@@ -2,6 +2,25 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* 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;
|
export default nextConfig;
|
||||||
|
|||||||
BIN
public/sample.jpg
Normal file
BIN
public/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -16,6 +16,7 @@ export function AppHeader() {
|
|||||||
const navRefs = React.useRef<Record<string, HTMLAnchorElement | null>>({});
|
const navRefs = React.useRef<Record<string, HTMLAnchorElement | null>>({});
|
||||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const navItemRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
|
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
|
||||||
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
||||||
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
||||||
@@ -95,29 +96,15 @@ export function AppHeader() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const nextPositions: Record<string, number> = {};
|
const nextPositions: Record<string, number> = {};
|
||||||
|
const nextWidths: Record<string, number> = {};
|
||||||
categories.forEach((cat) => {
|
categories.forEach((cat) => {
|
||||||
const a = navRefs.current[cat.slug];
|
const itemEl = navItemRefs.current[cat.slug];
|
||||||
if (a) {
|
if (!itemEl) return;
|
||||||
const r = a.getBoundingClientRect();
|
const r = itemEl.getBoundingClientRect();
|
||||||
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
|
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
|
||||||
}
|
nextWidths[cat.slug] = r.width; // 각 네비 항목의 실제 폭을 사용
|
||||||
});
|
});
|
||||||
setLeftPositions(nextPositions);
|
setLeftPositions(nextPositions);
|
||||||
|
|
||||||
// 각 블록의 가로 폭 = 다음 항목의 left까지 남은 거리
|
|
||||||
// 마지막 항목은 컨테이너 끝까지 확장되지 않도록 width를 지정하지 않음
|
|
||||||
const nextWidths: Record<string, number> = {};
|
|
||||||
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);
|
setBlockWidths(nextWidths);
|
||||||
|
|
||||||
// 패널 높이 = 블록들 중 최대 높이
|
// 패널 높이 = 블록들 중 최대 높이
|
||||||
@@ -166,11 +153,19 @@ export function AppHeader() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat, idx) => (
|
||||||
<div key={cat.id} className="relative group min-w-[80px] text-center" onMouseEnter={() => setOpenSlug(cat.slug)}>
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="relative group min-w-[80px] text-center"
|
||||||
|
onMouseEnter={() => setOpenSlug(cat.slug)}
|
||||||
|
ref={(el) => {
|
||||||
|
navItemRefs.current[cat.slug] = el;
|
||||||
|
}}
|
||||||
|
style={{ minWidth: idx === categories.length - 1 ? 120 : undefined }}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
|
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
|
||||||
className={`px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 ${
|
className={`px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||||
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
||||||
}`}
|
}`}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -197,7 +192,7 @@ export function AppHeader() {
|
|||||||
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
||||||
onMouseLeave={() => { scheduleClose(); }}
|
onMouseLeave={() => { scheduleClose(); }}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-4 w-full max-w-7xl mx-auto overflow-x-hidden">
|
<div className="px-4 py-4 w-full mx-auto overflow-x-hidden">
|
||||||
<div ref={panelRef} className="relative">
|
<div ref={panelRef} className="relative">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div
|
<div
|
||||||
@@ -206,7 +201,7 @@ export function AppHeader() {
|
|||||||
blockRefs.current[cat.slug] = el;
|
blockRefs.current[cat.slug] = el;
|
||||||
}}
|
}}
|
||||||
className="absolute top-0"
|
className="absolute top-0"
|
||||||
style={{ left: (leftPositions[cat.slug] ?? 0) + 0, width: blockWidths[cat.slug] ?? undefined }}
|
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
|
||||||
>
|
>
|
||||||
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -214,7 +209,7 @@ export function AppHeader() {
|
|||||||
<Link
|
<Link
|
||||||
key={b.id}
|
key={b.id}
|
||||||
href={`/boards/${b.id}`}
|
href={`/boards/${b.id}`}
|
||||||
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center ${
|
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
|
||||||
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
||||||
}`}
|
}`}
|
||||||
aria-current={activeBoardId === b.id ? "page" : undefined}
|
aria-current={activeBoardId === b.id ? "page" : undefined}
|
||||||
|
|||||||
@@ -1,30 +1,145 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
||||||
|
|
||||||
export function HeroBanner() {
|
export function HeroBanner() {
|
||||||
const [banners, setBanners] = useState<Banner[]>([]);
|
const [banners, setBanners] = useState<Banner[]>([]);
|
||||||
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<number | null>(null);
|
||||||
|
const startedAtRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
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(() => {
|
useEffect(() => {
|
||||||
if ((banners?.length ?? 0) < 2) return;
|
stopTicker();
|
||||||
const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000);
|
setProgress(0);
|
||||||
return () => clearInterval(t);
|
startTicker();
|
||||||
}, [banners]);
|
return () => stopTicker();
|
||||||
const slide = banners[idx];
|
}, [startTicker, stopTicker, activeIndex, isHovered, numSlides]);
|
||||||
if (!slide) return null;
|
|
||||||
const content = (
|
const goTo = useCallback((index: number) => {
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
if (numSlides === 0) return;
|
||||||
<img src={slide.imageUrl} alt={slide.title} style={{ width: 72, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
const next = (index + numSlides) % numSlides;
|
||||||
<h1 style={{ margin: 0 }}>{slide.title}</h1>
|
setActiveIndex(next);
|
||||||
</div>
|
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 (
|
return (
|
||||||
<section style={{ padding: 16, background: "#f5f5f5", borderRadius: 12, marginBottom: 16 }}>
|
<section
|
||||||
{slide.linkUrl ? <a href={slide.linkUrl}>{content}</a> : content}
|
className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white"
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
>
|
||||||
|
<div className="relative h-56 sm:h-72 md:h-[420px]">
|
||||||
|
<div
|
||||||
|
className="flex h-full w-full transition-transform duration-500 ease-out"
|
||||||
|
style={{ transform: `translate3d(${translatePercent}%, 0, 0)`, width: `${numSlides * 100}%` }}
|
||||||
|
>
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<div key={banner.id} className="relative h-full w-full shrink-0">
|
||||||
|
{banner.linkUrl ? (
|
||||||
|
<a href={banner.linkUrl} className="block h-full w-full focus:outline-none focus:ring-2 focus:ring-white/60" aria-label={banner.title}>
|
||||||
|
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||||
|
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6">
|
||||||
|
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
{numSlides > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous slide"
|
||||||
|
onClick={goPrev}
|
||||||
|
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Next slide"
|
||||||
|
onClick={goNext}
|
||||||
|
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M8.25 4.5 15.75 12l-7.5 7.5" /></svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar
|
||||||
|
{numSlides > 1 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/20">
|
||||||
|
<div className="h-full bg-white transition-[width] duration-100" style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{numSlides > 1 && (
|
||||||
|
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
||||||
|
{banners.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
aria-label={`Go to slide ${i + 1}`}
|
||||||
|
aria-current={activeIndex === i ? "true" : undefined}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`h-2 w-2 rounded-full transition ${activeIndex === i ? "bg-white" : "bg-white/40 hover:bg-white/70"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function RootLayout({
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
||||||
<div className="mx-auto max-w-7xl w-full">
|
<div className="mx-auto w-full">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
|
||||||
export default function Home({ searchParams }: { searchParams?: { sort?: "recent" | "popular" } }) {
|
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
||||||
const sort = searchParams?.sort ?? "recent";
|
const sp = await searchParams;
|
||||||
|
const sort = sp?.sort ?? "recent";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* 히어로 섹션: 상단 대형 비주얼 영역 */}
|
{/* 히어로 섹션: 상단 대형 비주얼 영역 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user