603 lines
28 KiB
TypeScript
603 lines
28 KiB
TypeScript
"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<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
|
|
const [openSlug, setOpenSlug] = React.useState<string | null>(null);
|
|
const [megaOpen, setMegaOpen] = 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 navItemRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
|
const navRowRef = React.useRef<HTMLDivElement | null>(null);
|
|
const navTextRefs = React.useRef<Record<string, HTMLSpanElement | null>>({});
|
|
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
|
|
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
|
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
|
const closeTimer = React.useRef<number | null>(null);
|
|
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
|
|
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
|
|
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
|
|
const [indicatorVisible, setIndicatorVisible] = React.useState<boolean>(false);
|
|
// 로그인 상태 확인 (전역 버튼 노출용)
|
|
const { data: authData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
|
"/api/me",
|
|
(u: string) => fetch(u).then((r) => r.json())
|
|
);
|
|
// 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출)
|
|
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<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]);
|
|
|
|
// 헤더 항목과 드롭다운 블록 정렬: 좌표 측정 및 패널 높이 계산
|
|
React.useEffect(() => {
|
|
if (!megaOpen) return;
|
|
const compute = () => {
|
|
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);
|
|
});
|
|
setBlockWidths(finalWidths);
|
|
|
|
// 우측 경계 초과 시 좌측으로 이동하여 뷰포트 안에 맞춤
|
|
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)에서만 설정
|
|
|
|
// 패널 높이 = 블록들 중 최대 높이
|
|
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 (
|
|
<header
|
|
ref={headerRef}
|
|
className={`relative flex items-center justify-between px-4 py-3 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60 ${
|
|
megaOpen ? "shadow-[0_6px_24px_rgba(0,0,0,0.10)]" : ""
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3 z-[100]">
|
|
<button
|
|
aria-label="메뉴 열기"
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="mobile-menu"
|
|
onClick={() => setMobileOpen((v) => !v)}
|
|
className="group inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 hover:border-neutral-300 hover:bg-neutral-100 transition-colors cursor-pointer xl:hidden"
|
|
>
|
|
<span className="flex flex-col items-center justify-center gap-1">
|
|
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
|
|
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
|
|
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
|
|
</span>
|
|
</button>
|
|
<Link href="/" aria-label="홈" className="shrink-0 flex items-center">
|
|
<SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
|
|
</Link>
|
|
</div>
|
|
<nav className="flex flex-1 items-center gap-4 justify-between">
|
|
{/* 데스크톱 메가메뉴 */}
|
|
<div
|
|
className="relative hidden xl:block pl-10"
|
|
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
|
onMouseLeave={() => { scheduleClose(); }}
|
|
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="relative flex items-center gap-0" ref={navRowRef}>
|
|
{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;
|
|
}}
|
|
>
|
|
<Link
|
|
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
|
|
className={`block w-full px-6 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${
|
|
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? "" : "text-neutral-700"
|
|
}`}
|
|
style={(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
|
|
ref={(el) => {
|
|
navRefs.current[cat.slug] = el;
|
|
}}
|
|
>
|
|
<span
|
|
ref={(el) => {
|
|
navTextRefs.current[cat.slug] = el;
|
|
}}
|
|
className="inline-block"
|
|
>
|
|
{cat.name}
|
|
</span>
|
|
</Link>
|
|
</div>
|
|
))}
|
|
{/* 공용 선택 인디케이터 바 (Figma 스타일) */}
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute bottom-0 left-0 transition-all duration-300 ease-out"
|
|
style={{
|
|
transform: `translateX(${indicatorLeft}px)`,
|
|
width: indicatorWidth,
|
|
height: 4,
|
|
opacity: indicatorVisible ? 1 : 0,
|
|
background: "var(--red-50, #F94B37)",
|
|
borderRadius: "var(--radius-24, 24px) var(--radius-24, 24px) 0 0",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={`fixed left-0 right-0 z-50 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60 shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${
|
|
megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
|
}`}
|
|
style={{ top: headerBottom }}
|
|
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
|
onMouseLeave={() => { scheduleClose(); }}
|
|
>
|
|
<div className="px-4 py-4 w-full mx-auto overflow-x-hidden">
|
|
<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), width: blockWidths[cat.slug] ?? undefined }}
|
|
>
|
|
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
|
<div className="mx-auto flex flex-col items-center gap-0 w-full">
|
|
{cat.boards.map((b) => (
|
|
<Link
|
|
key={b.id}
|
|
href={`/boards/${b.slug}`}
|
|
className={`rounded px-2 pb-4 text-sm transition-colors duration-150 text-center whitespace-nowrap ${
|
|
activeBoardId === b.slug
|
|
? "text-[var(--red-50,#F94B37)] font-semibold"
|
|
: "text-neutral-700 hover:text-[var(--red-50,#F94B37)]"
|
|
}`}
|
|
aria-current={activeBoardId === b.slug ? "page" : undefined}
|
|
>
|
|
{b.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div style={{ height: panelHeight }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="dummy" className="block"></div>
|
|
<div className="hidden xl:flex xl:flex-1 justify-end items-center gap-3">
|
|
<SearchBar/>
|
|
{authData?.user ? (
|
|
<>
|
|
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
|
|
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
|
|
<Link
|
|
href="/my-page"
|
|
aria-label="마이페이지"
|
|
className="h-full px-3 inline-flex items-center text-sm text-neutral-800 hover:bg-neutral-100 cursor-pointer truncate max-w-[220px]"
|
|
>
|
|
{authData.user.nickname}님 안녕하세요
|
|
</Link>
|
|
<span aria-hidden className="w-px h-5 bg-neutral-200" />
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await fetch("/api/auth/session", { method: "DELETE" });
|
|
} finally {
|
|
window.location.reload();
|
|
}
|
|
}}
|
|
className="h-full px-3 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none cursor-pointer"
|
|
aria-label="로그아웃"
|
|
>
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<Link
|
|
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
|
className="inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
aria-label="로그인"
|
|
>
|
|
로그인
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
<div
|
|
className={`fixed inset-0 h-[100vh] z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
|
onClick={() => setMobileOpen(false)}
|
|
aria-hidden={!mobileOpen}
|
|
>
|
|
<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 transition-transform duration-300 ${mobileOpen ? "translate-x-0" : "-translate-x-full"}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div className="mb-3 h-10 flex items-center justify-between">
|
|
</div>
|
|
<div className="flex flex-col gap-4">
|
|
{/* 미니 프로필 패널 */}
|
|
<div className="rounded-xl border border-neutral-200 p-3">
|
|
{meData?.user ? (
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar src={meData.user.profileImage} alt={meData.user.nickname || "프로필"} width={48} height={48} className="rounded-full" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-neutral-900 truncate">{meData.user.nickname}</span>
|
|
<GradeIcon grade={meData.user.grade} width={20} height={20} />
|
|
</div>
|
|
<div className="text-xs text-neutral-600">Lv.{meData.user.level} · {meData.user.points.toLocaleString()}점</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-neutral-700">로그인이 필요합니다</div>
|
|
<Link
|
|
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
|
onClick={() => setMobileOpen(false)}
|
|
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
aria-label="로그인"
|
|
>
|
|
로그인
|
|
</Link>
|
|
</div>
|
|
)}
|
|
{meData?.user && (
|
|
<div className="mt-3 flex justify-end">
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await fetch("/api/auth/session", { method: "DELETE" });
|
|
} finally {
|
|
setMobileOpen(false);
|
|
window.location.reload();
|
|
}
|
|
}}
|
|
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
aria-label="로그아웃"
|
|
>
|
|
로그아웃
|
|
</button>
|
|
</div>
|
|
)}
|
|
{meData?.user && (
|
|
<div className="grid grid-cols-3 gap-2 mt-3">
|
|
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리</Link>
|
|
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글</Link>
|
|
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<SearchBar />
|
|
<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.slug}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
|
|
{b.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
|