"use client"; // 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다. import Image from "next/image"; import Link from "next/link"; import { SearchBar } from "@/app/components/SearchBar"; import useSWR from "swr"; import { UserAvatar } from "@/app/components/UserAvatar"; import { GradeIcon } from "@/app/components/GradeIcon"; import React from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { SinglePageLogo } from "@/app/components/SinglePageLogo"; export function AppHeader() { const [categories, setCategories] = React.useState }>>([]); const [openSlug, setOpenSlug] = React.useState(null); const [megaOpen, setMegaOpen] = React.useState(false); const [mobileOpen, setMobileOpen] = React.useState(false); const headerRef = React.useRef(null); const [headerBottom, setHeaderBottom] = React.useState(0); const navRefs = React.useRef>({}); const panelRef = React.useRef(null); const blockRefs = React.useRef>({}); const navItemRefs = React.useRef>({}); const navRowRef = React.useRef(null); const navTextRefs = React.useRef>({}); const [leftPositions, setLeftPositions] = React.useState>({}); const [panelHeight, setPanelHeight] = React.useState(0); const [blockWidths, setBlockWidths] = React.useState>({}); const closeTimer = React.useRef(null); const [navMinWidths, setNavMinWidths] = React.useState>({}); const [indicatorLeft, setIndicatorLeft] = React.useState(0); const [indicatorWidth, setIndicatorWidth] = React.useState(0); const [indicatorVisible, setIndicatorVisible] = React.useState(false); // 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출) const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>( mobileOpen ? "/api/me" : null, (u: string) => fetch(u).then((r) => r.json()) ); // 현재 경로 기반 활성 보드/카테고리 계산 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.slug === activeBoardId)); return found?.slug ?? null; } if (pathname === "/boards") { return searchParams?.get("category") ?? null; } return null; }, [activeBoardId, pathname, searchParams, categories]); const cancelClose = React.useCallback(() => { if (closeTimer.current) { window.clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); const scheduleClose = React.useCallback(() => { cancelClose(); closeTimer.current = window.setTimeout(() => { setMegaOpen(false); setOpenSlug(null); }, 150); }, [cancelClose]); // 카테고리 로드 + 외부에서 새로고침 트리거 지원 const reloadCategories = React.useCallback(() => { fetch("/api/categories", { cache: "no-store" }) .then((r) => r.json()) .then((d) => setCategories(d?.categories || [])) .catch(() => setCategories([])); }, []); React.useEffect(() => { reloadCategories(); const onRefresh = () => reloadCategories(); window.addEventListener("categories:reload", onRefresh); return () => window.removeEventListener("categories:reload", onRefresh); }, [reloadCategories]); // 상단 네비게이션 선택/호버 인디케이터 업데이트 const updateIndicator = React.useCallback(() => { const container = navRowRef.current; if (!container) return; const targetSlug = (megaOpen && openSlug) ? openSlug : activeCategorySlug; if (!targetSlug) { setIndicatorVisible(false); return; } const itemEl = navItemRefs.current[targetSlug]; if (!itemEl) { setIndicatorVisible(false); return; } const cr = container.getBoundingClientRect(); const ir = itemEl.getBoundingClientRect(); const inset = 0; // 컨테이너(div) 너비 기준 const nextLeft = Math.max(0, ir.left - cr.left + inset); const nextWidth = Math.max(0, ir.width - inset * 2); setIndicatorLeft(nextLeft); setIndicatorWidth(nextWidth); setIndicatorVisible(true); }, [megaOpen, openSlug, activeCategorySlug]); React.useEffect(() => { updateIndicator(); }, [updateIndicator, categories]); React.useEffect(() => { const onResize = () => updateIndicator(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [updateIndicator]); // 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 (!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 = {}; const desiredWidths: Record = {}; const minWidthsBySlug: Record = { 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 = {}; 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, b: Record) => { 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]); // 헤더 항목과 드롭다운 블록 정렬: 좌표 측정 및 패널 높이 계산 React.useEffect(() => { if (!megaOpen) return; const compute = () => { const container = panelRef.current; if (!container) return; const containerRect = container.getBoundingClientRect(); const nextPositions: Record = {}; const desiredWidths: Record = {}; const minWidthsBySlug: Record = { 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 = {}; 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); // 우측 경계 초과 시 좌측으로 이동하여 뷰포트 안에 맞춤 const finalLefts: Record = {}; 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)에서만 설정 // 패널 높이 = 블록들 중 최대 높이 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]); // 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 (
{mobileOpen && (
setMobileOpen(false)}>
e.stopPropagation()}>
{/* 미니 프로필 패널 */}
{meData?.user ? (
{meData.user.nickname}
Lv.{meData.user.level} · {meData.user.points.toLocaleString()}점
) : (
로그인 정보를 불러오는 중...
)}
setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리 setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글 setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글
setMobileOpen(false)} className="inline-flex items-center justify-center h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100" aria-label="어드민(임시)" > 어드민(임시)
{categories.map((cat) => (
{cat.name}
{cat.boards.map((b) => ( setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900"> {b.name} ))}
))}
)}
); }