appheader

This commit is contained in:
koreacomp5
2025-11-03 10:22:44 +09:00
parent fadd402e63
commit f4e46c39fb

View File

@@ -21,11 +21,16 @@ export function AppHeader() {
const panelRef = React.useRef<HTMLDivElement | null>(null); const panelRef = React.useRef<HTMLDivElement | null>(null);
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({}); const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const navItemRefs = 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 [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
const [panelHeight, setPanelHeight] = React.useState<number>(0); const [panelHeight, setPanelHeight] = React.useState<number>(0);
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({}); const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
const closeTimer = React.useRef<number | null>(null); const closeTimer = React.useRef<number | null>(null);
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({}); 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: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>( const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
mobileOpen ? "/api/me" : null, mobileOpen ? "/api/me" : null,
@@ -80,6 +85,40 @@ export function AppHeader() {
return () => window.removeEventListener("categories:reload", onRefresh); return () => window.removeEventListener("categories:reload", onRefresh);
}, [reloadCategories]); }, [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로 메가메뉴 닫기 // ESC로 메가메뉴 닫기
React.useEffect(() => { React.useEffect(() => {
if (!megaOpen) return; if (!megaOpen) return;
@@ -302,7 +341,12 @@ export function AppHeader() {
}; };
}, []); }, []);
return ( return (
<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"> <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 ${
megaOpen ? "shadow-[0_6px_24px_rgba(0,0,0,0.10)]" : ""
}`}
>
<div className="flex items-center gap-3 z-[100]"> <div className="flex items-center gap-3 z-[100]">
<button <button
aria-label="메뉴 열기" aria-label="메뉴 열기"
@@ -333,7 +377,7 @@ export function AppHeader() {
if (!e.currentTarget.contains(next)) setMegaOpen(false); if (!e.currentTarget.contains(next)) setMegaOpen(false);
}} }}
> >
<div className="flex items-center gap-8"> <div className="relative flex items-center gap-8" ref={navRowRef}>
{categories.map((cat, idx) => ( {categories.map((cat, idx) => (
<div <div
key={cat.id} key={cat.id}
@@ -342,31 +386,44 @@ export function AppHeader() {
ref={(el) => { ref={(el) => {
navItemRefs.current[cat.slug] = el; navItemRefs.current[cat.slug] = el;
}} }}
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
> >
<Link <Link
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`} href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${ className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700" (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) => { ref={(el) => {
navRefs.current[cat.slug] = el; navRefs.current[cat.slug] = el;
}} }}
> >
{cat.name} <span
ref={(el) => {
navTextRefs.current[cat.slug] = el;
}}
className="inline-block"
>
{cat.name}
</span>
</Link> </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 ${
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug
? "scale-x-100 opacity-100"
: "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
}`}
/>
</div> </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>
<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 ${ className={`fixed left-0 right-0 z-50 bg-white shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${
megaOpen ? "opacity-100" : "pointer-events-none opacity-0" megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`} }`}
style={{ top: headerBottom }} style={{ top: headerBottom }}
@@ -390,9 +447,10 @@ export function AppHeader() {
<Link <Link
key={b.id} key={b.id}
href={`/boards/${b.slug}`} href={`/boards/${b.slug}`}
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${ className={`rounded px-2 py-1 text-sm transition-colors duration-150 text-center whitespace-nowrap ${
activeBoardId === b.slug ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700" activeBoardId === b.slug ? "font-semibold" : "text-neutral-700 hover:text-neutral-900 hover:bg-neutral-100"
}`} }`}
style={activeBoardId === b.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
aria-current={activeBoardId === b.slug ? "page" : undefined} aria-current={activeBoardId === b.slug ? "page" : undefined}
> >
{b.name} {b.name}