header 중간간
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
- [x] 검색 엔드포인트/페이지 연동(`src/app/search/page.tsx`)
|
- [x] 검색 엔드포인트/페이지 연동(`src/app/search/page.tsx`)
|
||||||
|
|
||||||
7) 스타일/반응형/테마
|
7) 스타일/반응형/테마
|
||||||
- [ ] 모바일 메뉴 토글(햄버거) 및 접근성 속성(aria-* ) 적용
|
- [x] 모바일 메뉴 토글(햄버거) 및 접근성 속성(aria-* ) 적용
|
||||||
- [ ] 라이트/다크 모드 색상 대응
|
- [ ] 라이트/다크 모드 색상 대응
|
||||||
|
|
||||||
8) 연동/테스트
|
8) 연동/테스트
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 795 KiB After Width: | Height: | Size: 795 KiB |
@@ -2,69 +2,111 @@
|
|||||||
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
|
||||||
import { SearchBar } from "@/app/components/SearchBar";
|
import { SearchBar } from "@/app/components/SearchBar";
|
||||||
import { Button } from "@/app/components/ui/Button";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const [user, setUser] = React.useState<{ nickname: string } | null>(null);
|
|
||||||
const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
|
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 [openSlug, setOpenSlug] = React.useState<string | null>(null);
|
||||||
// 헤더 마운트 시 세션 존재 여부를 조회해 로그인/로그아웃 UI를 제어합니다.
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
// 카테고리 로드
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/auth/session")
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setUser(d?.ok ? d.user : null))
|
|
||||||
.catch(() => setUser(null));
|
|
||||||
fetch("/api/categories", { cache: "no-store" })
|
fetch("/api/categories", { cache: "no-store" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setCategories(d?.categories || []))
|
.then((d) => setCategories(d?.categories || []))
|
||||||
.catch(() => setCategories([]));
|
.catch(() => setCategories([]));
|
||||||
}, []);
|
}, []);
|
||||||
const onLogout = async () => {
|
|
||||||
await fetch("/api/auth/session", { method: "DELETE" });
|
|
||||||
setUser(null);
|
|
||||||
location.reload();
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: 12 }}>
|
<header className="flex items-center justify-between px-4 py-3 border-b bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/" aria-label="홈">
|
<button
|
||||||
|
aria-label="메뉴 열기"
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
onClick={() => setMobileOpen((v) => !v)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-neutral-200 md:hidden"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
<Image src="/logo.png" alt="logo" width={120} height={28} priority />
|
<Image src="/logo.png" alt="logo" width={120} height={28} priority />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
<nav className="hidden items-center gap-4 md:flex">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => {
|
||||||
|
const isOpen = openSlug === cat.slug;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onMouseEnter={() => setOpenSlug(cat.slug)}
|
onMouseEnter={() => setOpenSlug(cat.slug)}
|
||||||
onMouseLeave={() => setOpenSlug((s) => (s === cat.slug ? null : s))}
|
onMouseLeave={() => setOpenSlug((s) => (s === cat.slug ? null : s))}
|
||||||
style={{ position: "relative" }}
|
className="relative group"
|
||||||
>
|
>
|
||||||
<Link href={`/boards?category=${cat.slug}`}>{cat.name}</Link>
|
<div className="relative">
|
||||||
{openSlug === cat.slug && (
|
<Link
|
||||||
<div style={{ position: "absolute", top: "100%", left: 0, background: "white", border: "1px solid #eee", padding: 8, minWidth: 200, zIndex: 50 }}>
|
href={`/boards?category=${cat.slug}`}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
className="px-2 py-2 text-sm font-medium text-neutral-700 transition-colors duration-200 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
{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 ${
|
||||||
|
isOpen ? "scale-x-100 opacity-100" : "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`absolute left-0 top-full z-50 mt-2 min-w-56 rounded-md border border-neutral-100 bg-white p-2 shadow-[0_8px_24px_rgba(0,0,0,0.08)] transition-all duration-200 ${
|
||||||
|
isOpen ? "opacity-100 translate-y-0" : "pointer-events-none opacity-0 -translate-y-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link key={b.id} href={`/boards/${b.id}`}>{b.name}</Link>
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={`/boards/${b.id}`}
|
||||||
|
className="rounded px-2 py-1 text-sm text-neutral-700 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
{b.name}
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<ThemeToggle />
|
|
||||||
{user ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span>{user.nickname}님</span>
|
|
||||||
<Button variant="ghost" onClick={onLogout}>로그아웃</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Link href="/login">로그인</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
{mobileOpen && (
|
||||||
|
<div id="mobile-menu" role="dialog" aria-modal="true" className="fixed inset-0 z-50 bg-black/40" onClick={() => setMobileOpen(false)}>
|
||||||
|
<div className="absolute left-0 top-0 h-full w-4/5 max-w-sm bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<strong>메뉴</strong>
|
||||||
|
<button aria-label="닫기" className="text-sm text-neutral-600 hover:text-neutral-900" onClick={() => setMobileOpen(false)}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<SearchBar />
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<div key={cat.id}>
|
||||||
|
<div className="my-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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user