appheader
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user