This commit is contained in:
mota
2025-10-13 11:34:00 +09:00
parent 6e4563a40a
commit 4b1c60a9bf
2 changed files with 137 additions and 33 deletions

View File

@@ -11,7 +11,7 @@ async function upsertCategories() {
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" }, { name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" }, { name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" }, { name: "소통방", slug: "community", sortOrder: 6, status: "active" },
{ name: "광고/제휴 문반", slug: "ads-affiliates", sortOrder: 7, status: "active" }, { name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
]; ];
const map = {}; const map = {};
for (const c of categories) { for (const c of categories) {

View File

@@ -8,7 +8,16 @@ import React from "react";
export function AppHeader() { export function AppHeader() {
const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]); const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
const [openSlug, setOpenSlug] = React.useState<string | null>(null); const [openSlug, setOpenSlug] = React.useState<string | null>(null);
const [megaOpen, setMegaOpen] = React.useState(false);
const [mobileOpen, setMobileOpen] = React.useState(false); const [mobileOpen, setMobileOpen] = React.useState(false);
const headerRef = React.useRef<HTMLDivElement | null>(null);
const [headerBottom, setHeaderBottom] = React.useState<number>(0);
const navRefs = React.useRef<Record<string, HTMLAnchorElement | null>>({});
const panelRef = React.useRef<HTMLDivElement | null>(null);
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
const [panelHeight, setPanelHeight] = React.useState<number>(0);
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
// 카테고리 로드 // 카테고리 로드
React.useEffect(() => { React.useEffect(() => {
fetch("/api/categories", { cache: "no-store" }) fetch("/api/categories", { cache: "no-store" })
@@ -16,8 +25,79 @@ export function AppHeader() {
.then((d) => setCategories(d?.categories || [])) .then((d) => setCategories(d?.categories || []))
.catch(() => setCategories([])); .catch(() => setCategories([]));
}, []); }, []);
// ESC로 메가메뉴 닫기
React.useEffect(() => {
if (!megaOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMegaOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [megaOpen]);
// 헤더 bottom 좌표를 계산해 fixed 패널 top에 적용
React.useEffect(() => {
const measure = () => {
const el = headerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setHeaderBottom(rect.bottom);
};
measure();
window.addEventListener("resize", measure);
window.addEventListener("scroll", measure, { passive: true });
return () => {
window.removeEventListener("resize", measure);
window.removeEventListener("scroll", measure);
};
}, []);
// 헤더 항목과 드롭다운 블록 정렬: 좌표 측정 및 패널 높이 계산
React.useEffect(() => {
if (!megaOpen) return;
const compute = () => {
const container = panelRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const nextPositions: Record<string, number> = {};
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);
}
});
setLeftPositions(nextPositions);
// 각 블록의 가로 폭 = 다음 항목의 left까지 남은 거리 (마지막은 컨테이너 오른쪽 끝)
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]!;
const nextLeft = idx < ordered.length - 1 ? nextPositions[ordered[idx + 1].slug]! : containerRect.width;
const width = Math.max(0, nextLeft - currentLeft);
nextWidths[cat.slug] = width;
});
setBlockWidths(nextWidths);
// 패널 높이 = 블록들 중 최대 높이
let maxH = 0;
categories.forEach((cat) => {
const el = blockRefs.current[cat.slug];
if (el) maxH = Math.max(maxH, el.offsetHeight);
});
setPanelHeight(maxH);
};
compute();
const onResize = () => compute();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [megaOpen, categories]);
return ( return (
<header className="relative flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60"> <header ref={headerRef} className="relative flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="flex items-center gap-3 z-[100]"> <div className="flex items-center gap-3 z-[100]">
<button <button
aria-label="메뉴 열기" aria-label="메뉴 열기"
@@ -36,51 +116,75 @@ export function AppHeader() {
<Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 md:w-28 xl:w-[120px] h-auto" /> <Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 md:w-28 xl:w-[120px] h-auto" />
</Link> </Link>
</div> </div>
<nav className="flex items-center gap-4"> <nav className="flex flex-1 items-center gap-4 justify-between">
<div className="hidden items-center gap-4 xl:flex"> {/* 데스크톱 메가메뉴 */}
{categories.map((cat) => { <div
const isOpen = openSlug === cat.slug; className="relative hidden xl:block pl-10"
return ( onMouseEnter={() => setMegaOpen(true)}
<div onMouseLeave={() => setMegaOpen(false)}
key={cat.id} onFocusCapture={() => setMegaOpen(true)}
onMouseEnter={() => setOpenSlug(cat.slug)} onBlurCapture={(e) => {
onMouseLeave={() => setOpenSlug((s) => (s === cat.slug ? null : s))} const next = (e as unknown as React.FocusEvent<HTMLDivElement>).relatedTarget as Node | null;
className="relative group" if (!e.currentTarget.contains(next)) setMegaOpen(false);
> }}
<div className="relative"> >
<div className="flex items-center gap-8">
{categories.map((cat) => (
<div key={cat.id} className="relative group" onMouseEnter={() => setOpenSlug(cat.slug)}>
<Link <Link
href={`/boards?category=${cat.slug}`} href={`/boards?category=${cat.slug}`}
className="px-2 py-2 text-sm font-medium text-neutral-700 transition-colors duration-200 hover:text-neutral-900" className="px-2 py-2 text-sm font-medium text-neutral-700 transition-colors duration-200 hover:text-neutral-900"
ref={(el) => {
navRefs.current[cat.slug] = el;
}}
> >
{cat.name} {cat.name}
</Link> </Link>
<span <span
className={`pointer-events-none absolute left-1 right-1 -bottom-0.5 h-0.5 origin-left rounded bg-neutral-900 transition-all duration-200 ${ className={`pointer-events-none absolute left-1 right-1 -bottom-0.5 h-0.5 origin-left rounded bg-neutral-900 transition-all duration-200 ${
isOpen ? "scale-x-100 opacity-100" : "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100" openSlug === cat.slug ? "scale-x-100 opacity-100" : "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
}`} }`}
/> />
</div> </div>
<div ))}
className={`absolute left-0 top-full z-50 mt-2 min-w-56 rounded-md border border-neutral-100 bg-white p-2 shadow-[0_8px_24px_rgba(0,0,0,0.08)] transition-all duration-200 ${ </div>
isOpen ? "opacity-100 translate-y-0" : "pointer-events-none opacity-0 -translate-y-1" <div
}`} className={`fixed left-0 right-0 z-50 border-t bg-white shadow-[0_8px_24px_rgba(0,0,0,0.08)] transition-all duration-200 ${
> megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
<div className="flex flex-col gap-1"> }`}
{cat.boards.map((b) => ( style={{ top: headerBottom }}
<Link >
key={b.id} <div className="px-4 py-4 w-screen">
href={`/boards/${b.id}`} <div ref={panelRef} className="relative">
className="rounded px-2 py-1 text-sm text-neutral-700 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900" {categories.map((cat) => (
> <div
{b.name} key={cat.id}
</Link> ref={(el) => {
))} blockRefs.current[cat.slug] = el;
</div> }}
className="absolute top-0"
style={{ left: (leftPositions[cat.slug] ?? 0) + 0, width: blockWidths[cat.slug] ?? undefined }}
>
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
<div className="flex flex-col gap-3">
{cat.boards.map((b) => (
<Link
key={b.id}
href={`/boards/${b.id}`}
className="rounded px-2 py-1 text-sm text-neutral-700 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900"
>
{b.name}
</Link>
))}
</div>
</div>
))}
<div style={{ height: panelHeight }} />
</div> </div>
</div> </div>
); </div>
})}
</div> </div>
<div id="dummy" className="block"></div>
<div className="hidden md:block"> <div className="hidden md:block">
<SearchBar/> <SearchBar/>
</div> </div>