Files
msgapp/src/app/components/AppHeader.tsx

435 lines
19 KiB
TypeScript
Raw Normal View History

2025-10-10 11:22:43 +09:00
"use client";
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
import Image from "next/image";
import Link from "next/link";
import { SearchBar } from "@/app/components/SearchBar";
2025-10-10 11:22:43 +09:00
import React from "react";
2025-10-13 16:05:24 +09:00
import { usePathname, useSearchParams } from "next/navigation";
export function AppHeader() {
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>>({});
2025-10-13 18:06:46 +09:00
const navItemRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
2025-10-13 11:34:00 +09:00
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 14:41:51 +09:00
const closeTimer = React.useRef<number | null>(null);
2025-10-31 16:27:16 +09:00
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
2025-10-13 14:41:51 +09:00
2025-10-13 16:05:24 +09:00
// 현재 경로 기반 활성 보드/카테고리 계산
const pathname = usePathname();
const searchParams = useSearchParams();
const activeBoardId = React.useMemo(() => {
if (!pathname) return null;
const parts = pathname.split("/").filter(Boolean);
if (parts[0] === "boards" && parts[1]) return parts[1];
return null;
}, [pathname]);
const activeCategorySlug = React.useMemo(() => {
if (activeBoardId) {
const found = categories.find((c) => c.boards.some((b) => b.id === activeBoardId));
return found?.slug ?? null;
}
if (pathname === "/boards") {
return searchParams?.get("category") ?? null;
}
return null;
}, [activeBoardId, pathname, searchParams, categories]);
2025-10-13 14:41:51 +09:00
const cancelClose = React.useCallback(() => {
if (closeTimer.current) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const scheduleClose = React.useCallback(() => {
cancelClose();
2025-10-13 19:45:01 +09:00
closeTimer.current = window.setTimeout(() => {
setMegaOpen(false);
setOpenSlug(null);
}, 150);
2025-10-13 14:41:51 +09:00
}, [cancelClose]);
2025-10-31 00:02:36 +09:00
// 카테고리 로드 + 외부에서 새로고침 트리거 지원
const reloadCategories = React.useCallback(() => {
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-31 00:02:36 +09:00
React.useEffect(() => {
reloadCategories();
const onRefresh = () => reloadCategories();
window.addEventListener("categories:reload", onRefresh);
return () => window.removeEventListener("categories:reload", onRefresh);
}, [reloadCategories]);
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);
};
}, []);
2025-10-31 16:27:16 +09:00
// (요구) 상위 카테고리 폭을 키우지 않음: 측정은 하되 상위 폭 업데이트는 하지 않음
React.useEffect(() => {
if (!categories.length) return;
const measure = () => {
// 상위 폭 미변경: 측정만 수행하고 상태는 건드리지 않음
};
// 레이아웃 안정화 이후(다음 두 프레임)에 1회 측정
const raf1 = window.requestAnimationFrame(() => {
const raf2 = window.requestAnimationFrame(() => measure());
(measure as any)._raf2 = raf2;
});
const onResize = () => measure();
window.addEventListener("resize", onResize);
return () => {
window.cancelAnimationFrame(raf1);
if ((measure as any)._raf2) window.cancelAnimationFrame((measure as any)._raf2);
window.removeEventListener("resize", onResize);
};
}, [categories]);
// 초기 로드/리사이즈 시, 드롭다운 최종 폭을 선계산(상위 폭은 키우지 않음)
React.useEffect(() => {
if (!categories.length) return;
const recomputeOnce = () => {
const container = panelRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const nextPositions: Record<string, number> = {};
const desiredWidths: Record<string, number> = {};
const minWidthsBySlug: Record<string, number> = { community: 200 };
categories.forEach((cat) => {
const itemEl = navItemRefs.current[cat.slug];
if (!itemEl) return;
const r = itemEl.getBoundingClientRect();
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
const baseWidth = r.width; // 상위 아이템 실제 폭
const minWidth = minWidthsBySlug[cat.slug] ?? baseWidth;
const blockEl = blockRefs.current[cat.slug];
let maxChildWidth = 0;
if (blockEl) {
const links = blockEl.querySelectorAll("a");
links.forEach((a) => {
const el = a as HTMLElement;
const rectW = el.getBoundingClientRect().width;
const cs = window.getComputedStyle(el);
const ml = parseFloat(cs.marginLeft || "0");
const mr = parseFloat(cs.marginRight || "0");
const w = Math.ceil(rectW + ml + mr);
if (w > maxChildWidth) maxChildWidth = w;
});
if (maxChildWidth === 0) {
const contentEl = blockEl.firstElementChild as HTMLElement | null;
maxChildWidth = Math.ceil(contentEl?.getBoundingClientRect().width ?? blockEl.getBoundingClientRect().width);
}
}
// 하위 컨테이너 폭을 상위 폭으로 상한(clamp)
const measuredSub = Math.max(maxChildWidth, 0);
desiredWidths[cat.slug] = Math.min(baseWidth, Math.max(minWidth, measuredSub));
});
const finalWidths: 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 desired = desiredWidths[cat.slug] ?? 0;
const maxAvail = idx < ordered.length - 1
? Math.max(0, nextPositions[ordered[idx + 1].slug]! - currentLeft)
: Math.max(0, containerRect.width - currentLeft);
finalWidths[cat.slug] = Math.min(desired, maxAvail);
});
// 상태 업데이트는 변경된 경우에만 수행
const eq = (a: Record<string, number>, b: Record<string, number>) => {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) { if (a[k] !== b[k]) return false; }
return true;
};
setBlockWidths((prev) => (eq(prev, finalWidths) ? prev : finalWidths));
};
const raf = window.requestAnimationFrame(() => recomputeOnce());
const onResize = () => recomputeOnce();
window.addEventListener("resize", onResize);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, [categories]);
2025-10-13 11:34:00 +09:00
// 헤더 항목과 드롭다운 블록 정렬: 좌표 측정 및 패널 높이 계산
React.useEffect(() => {
if (!megaOpen) return;
const compute = () => {
const container = panelRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const nextPositions: Record<string, number> = {};
2025-10-13 19:45:01 +09:00
const desiredWidths: Record<string, number> = {};
const minWidthsBySlug: Record<string, number> = { community: 200 };
2025-10-13 11:34:00 +09:00
categories.forEach((cat) => {
2025-10-13 18:06:46 +09:00
const itemEl = navItemRefs.current[cat.slug];
if (!itemEl) return;
const r = itemEl.getBoundingClientRect();
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
2025-10-31 16:27:16 +09:00
const baseWidth = r.width; // 상위 항목 실제 폭
2025-10-13 19:45:01 +09:00
const minWidth = minWidthsBySlug[cat.slug] ?? baseWidth;
2025-10-31 16:27:16 +09:00
// 하위 카테고리(드롭다운) 버튼들의 최대 실제 폭(패딩+마진 포함) 측정
const blockEl = blockRefs.current[cat.slug];
let maxChildWidth = 0;
if (blockEl) {
const links = blockEl.querySelectorAll("a");
links.forEach((a) => {
const el = a as HTMLElement;
const rectW = el.getBoundingClientRect().width;
const cs = window.getComputedStyle(el);
const ml = parseFloat(cs.marginLeft || "0");
const mr = parseFloat(cs.marginRight || "0");
const w = Math.ceil(rectW + ml + mr);
if (w > maxChildWidth) maxChildWidth = w;
});
if (maxChildWidth === 0) {
const contentEl = blockEl.firstElementChild as HTMLElement | null;
maxChildWidth = Math.ceil(contentEl?.getBoundingClientRect().width ?? blockEl.getBoundingClientRect().width);
}
}
// 하위 컨테이너 폭을 상위 폭으로 상한(clamp)
const measuredSub = Math.max(maxChildWidth, 0);
desiredWidths[cat.slug] = Math.min(baseWidth, Math.max(minWidth, measuredSub));
2025-10-13 11:34:00 +09:00
});
2025-10-13 19:45:01 +09:00
// 원하는 최소 폭을 기준으로 하되, 다음 항목의 시작점까지를 넘지 않도록 클램프
const finalWidths: 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 desired = desiredWidths[cat.slug] ?? 0;
const maxAvail = idx < ordered.length - 1
? Math.max(0, nextPositions[ordered[idx + 1].slug]! - currentLeft)
: Math.max(0, containerRect.width - currentLeft);
finalWidths[cat.slug] = Math.min(desired, maxAvail);
});
setBlockWidths(finalWidths);
2025-10-13 11:34:00 +09:00
2025-10-31 16:27:16 +09:00
// 우측 경계 초과 시 좌측으로 이동하여 뷰포트 안에 맞춤
const finalLefts: Record<string, number> = {};
ordered.forEach((cat) => {
const currentLeft = nextPositions[cat.slug]!;
const width = finalWidths[cat.slug] ?? 0;
const maxLeft = Math.max(0, containerRect.width - width);
finalLefts[cat.slug] = Math.max(0, Math.min(currentLeft, maxLeft));
});
setLeftPositions(finalLefts);
// 상위 카테고리 최소 폭은 초기 측정(useEffect)에서만 설정
2025-10-13 11:34:00 +09:00
// 패널 높이 = 블록들 중 최대 높이
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-31 16:27:16 +09:00
// xl 이상으로 뷰포트가 커지면 모바일 사이드바를 자동으로 닫음
React.useEffect(() => {
const mql = window.matchMedia("(min-width: 1280px)");
const onChange = (e: any) => {
if (e.matches) setMobileOpen(false);
};
// 초기 상태 체크 (이미 xl 이상인 경우 닫기)
if (mql.matches) setMobileOpen(false);
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
} else {
// Safari 등 구형 브라우저 호환
mql.addListener(onChange);
}
return () => {
if (typeof mql.removeEventListener === "function") {
mql.removeEventListener("change", onChange);
} else {
mql.removeListener(onChange);
}
};
}, []);
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-31 00:02:17 +09:00
<Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 xl:w-[120px] h-auto" />
</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"
2025-10-13 14:41:51 +09:00
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
onMouseLeave={() => { scheduleClose(); }}
2025-10-13 11:34:00 +09:00
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">
2025-10-13 18:06:46 +09:00
{categories.map((cat, idx) => (
<div
key={cat.id}
className="relative group min-w-[80px] text-center"
onMouseEnter={() => setOpenSlug(cat.slug)}
ref={(el) => {
navItemRefs.current[cat.slug] = el;
}}
2025-10-31 16:27:16 +09:00
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
2025-10-13 18:06:46 +09:00
>
2025-10-13 08:49:51 +09:00
<Link
2025-10-13 16:05:24 +09:00
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
2025-10-31 16:27:16 +09:00
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
2025-10-13 16:05:24 +09:00
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
}`}
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 19:45:01 +09:00
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug
2025-10-13 16:05:24 +09:00
? "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 }}
2025-10-13 14:41:51 +09:00
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
onMouseLeave={() => { scheduleClose(); }}
2025-10-13 11:34:00 +09:00
>
2025-10-13 18:06:46 +09:00
<div className="px-4 py-4 w-full mx-auto overflow-x-hidden">
2025-10-13 11:34:00 +09:00
<div ref={panelRef} className="relative">
{categories.map((cat) => (
<div
key={cat.id}
ref={(el) => {
blockRefs.current[cat.slug] = el;
}}
className="absolute top-0"
2025-10-13 18:06:46 +09:00
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
2025-10-13 11:34:00 +09:00
>
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
2025-10-31 16:27:16 +09:00
<div className="mx-auto flex flex-col items-center gap-3 w-full">
2025-10-13 11:34:00 +09:00
{cat.boards.map((b) => (
<Link
key={b.id}
href={`/boards/${b.id}`}
2025-10-13 18:06:46 +09:00
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
2025-10-13 16:05:24 +09:00
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
}`}
aria-current={activeBoardId === b.id ? "page" : undefined}
2025-10-13 11:34:00 +09:00
>
{b.name}
</Link>
))}
</div>
</div>
))}
<div style={{ height: panelHeight }} />
</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-31 16:27:16 +09:00
<div className="hidden xl:flex xl:flex-1 justify-end">
2025-10-13 09:13:52 +09:00
<SearchBar/>
</div>
</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>
)}
</header>
);
}