2025-10-10 11:22:43 +09:00
|
|
|
"use client";
|
2025-10-10 16:07:56 +09:00
|
|
|
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
2025-10-13 08:01:24 +09:00
|
|
|
import Image from "next/image";
|
|
|
|
|
import Link from "next/link";
|
2025-10-09 16:35:19 +09:00
|
|
|
import { SearchBar } from "@/app/components/SearchBar";
|
2025-10-10 11:22:43 +09:00
|
|
|
import React from "react";
|
2025-10-09 15:36:13 +09:00
|
|
|
|
2025-10-09 15:22:05 +09:00
|
|
|
export function AppHeader() {
|
2025-10-13 08:13:53 +09:00
|
|
|
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);
|
2025-10-13 11:34:00 +09:00
|
|
|
const [megaOpen, setMegaOpen] = React.useState(false);
|
2025-10-13 08:49:51 +09:00
|
|
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
2025-10-13 11:34:00 +09:00
|
|
|
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>>({});
|
2025-10-13 08:49:51 +09:00
|
|
|
// 카테고리 로드
|
2025-10-10 11:22:43 +09:00
|
|
|
React.useEffect(() => {
|
2025-10-13 08:13:53 +09:00
|
|
|
fetch("/api/categories", { cache: "no-store" })
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((d) => setCategories(d?.categories || []))
|
|
|
|
|
.catch(() => setCategories([]));
|
2025-10-10 11:22:43 +09:00
|
|
|
}, []);
|
2025-10-13 11:34:00 +09:00
|
|
|
|
|
|
|
|
// 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]);
|
2025-10-09 15:22:05 +09:00
|
|
|
return (
|
2025-10-13 11:34:00 +09:00
|
|
|
<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">
|
2025-10-13 09:45:53 +09:00
|
|
|
<div className="flex items-center gap-3 z-[100]">
|
2025-10-13 08:49:51 +09:00
|
|
|
<button
|
|
|
|
|
aria-label="메뉴 열기"
|
|
|
|
|
aria-expanded={mobileOpen}
|
|
|
|
|
aria-controls="mobile-menu"
|
|
|
|
|
onClick={() => setMobileOpen((v) => !v)}
|
2025-10-13 09:45:53 +09:00
|
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 xl:hidden"
|
2025-10-13 08:49:51 +09:00
|
|
|
>
|
|
|
|
|
<span className="flex flex-col items-center justify-center gap-1">
|
|
|
|
|
<span className="block h-0.5 w-4 bg-neutral-800" />
|
|
|
|
|
<span className="block h-0.5 w-4 bg-neutral-800" />
|
|
|
|
|
<span className="block h-0.5 w-4 bg-neutral-800" />
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
<Link href="/" aria-label="홈" className="shrink-0">
|
2025-10-13 09:45:53 +09:00
|
|
|
<Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 md:w-28 xl:w-[120px] h-auto" />
|
2025-10-13 08:01:24 +09:00
|
|
|
</Link>
|
|
|
|
|
</div>
|
2025-10-13 11:34:00 +09:00
|
|
|
<nav className="flex flex-1 items-center gap-4 justify-between">
|
|
|
|
|
{/* 데스크톱 메가메뉴 */}
|
|
|
|
|
<div
|
|
|
|
|
className="relative hidden xl:block pl-10"
|
|
|
|
|
onMouseEnter={() => setMegaOpen(true)}
|
|
|
|
|
onMouseLeave={() => setMegaOpen(false)}
|
|
|
|
|
onFocusCapture={() => setMegaOpen(true)}
|
|
|
|
|
onBlurCapture={(e) => {
|
|
|
|
|
const next = (e as unknown as React.FocusEvent<HTMLDivElement>).relatedTarget as Node | null;
|
|
|
|
|
if (!e.currentTarget.contains(next)) setMegaOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-8">
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
<div key={cat.id} className="relative group" onMouseEnter={() => setOpenSlug(cat.slug)}>
|
2025-10-13 08:49:51 +09:00
|
|
|
<Link
|
|
|
|
|
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"
|
2025-10-13 11:34:00 +09:00
|
|
|
ref={(el) => {
|
|
|
|
|
navRefs.current[cat.slug] = el;
|
|
|
|
|
}}
|
2025-10-13 08:49:51 +09:00
|
|
|
>
|
|
|
|
|
{cat.name}
|
|
|
|
|
</Link>
|
|
|
|
|
<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 ${
|
2025-10-13 11:34:00 +09:00
|
|
|
openSlug === cat.slug ? "scale-x-100 opacity-100" : "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
|
2025-10-13 08:49:51 +09:00
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-13 11:34:00 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<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"
|
|
|
|
|
}`}
|
|
|
|
|
style={{ top: headerBottom }}
|
|
|
|
|
>
|
|
|
|
|
<div className="px-4 py-4 w-screen">
|
|
|
|
|
<div ref={panelRef} className="relative">
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
<div
|
|
|
|
|
key={cat.id}
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
blockRefs.current[cat.slug] = el;
|
|
|
|
|
}}
|
|
|
|
|
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 }} />
|
2025-10-13 08:13:53 +09:00
|
|
|
</div>
|
2025-10-13 08:49:51 +09:00
|
|
|
</div>
|
2025-10-13 11:34:00 +09:00
|
|
|
</div>
|
2025-10-13 09:13:52 +09:00
|
|
|
</div>
|
2025-10-13 11:34:00 +09:00
|
|
|
<div id="dummy" className="block"></div>
|
2025-10-13 09:13:52 +09:00
|
|
|
<div className="hidden md:block">
|
|
|
|
|
<SearchBar/>
|
|
|
|
|
</div>
|
2025-10-09 15:22:05 +09:00
|
|
|
</nav>
|
2025-10-13 08:49:51 +09:00
|
|
|
{mobileOpen && (
|
2025-10-13 09:45:53 +09:00
|
|
|
<div className="fixed inset-0 h-[100vh] z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
2025-10-13 10:07:10 +09:00
|
|
|
<div className="absolute left-0 xl:top-0 h-[100vh] w-11/12 max-w-md bg-white p-4 shadow-xl overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
2025-10-13 09:45:53 +09:00
|
|
|
<div className="mb-3 h-10 flex items-center justify-between">
|
2025-10-13 08:49:51 +09:00
|
|
|
</div>
|
2025-10-13 10:07:10 +09:00
|
|
|
<div className="flex flex-col gap-4">
|
2025-10-13 08:49:51 +09:00
|
|
|
<SearchBar />
|
2025-10-13 10:07:10 +09:00
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
<div key={cat.id}>
|
|
|
|
|
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
{cat.boards.map((b) => (
|
|
|
|
|
<Link key={b.id} href={`/boards/${b.id}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
|
|
|
|
|
{b.name}
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-10-13 08:49:51 +09:00
|
|
|
</div>
|
2025-10-13 10:07:10 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
2025-10-13 08:49:51 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-09 15:22:05 +09:00
|
|
|
</header>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|