feat(nav): 글로벌 sticky 헤더 + 컴팩트 검색

- web/components/TopNav.tsx: sticky top header
  · 좌: Stock Chart 로고 (→ /)
  · 중: 컴팩트 검색 input + popover (debounced 200ms, with_sparkline=false 빠른 응답)
  · 우: ★ 관심 (→ /watchlist)
- 바깥 클릭/Esc 로 popover 닫힘
- 결과 클릭 시 자동 닫힘 + 검색어 클리어
- web/app/layout.tsx: <body> 최상단에 <TopNav /> 부착

토스 톱바 톤. 모든 페이지에서 검색 가능 — 홈으로 돌아갈 필요 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-owner
2026-05-28 15:10:14 +09:00
parent 85da979fbe
commit 300f7beef0
2 changed files with 131 additions and 1 deletions

126
web/components/TopNav.tsx Normal file
View File

@@ -0,0 +1,126 @@
"use client";
// 글로벌 sticky 헤더 — 모든 페이지 상단에 고정. 토스 톱바 톤.
// 좌: 로고/타이틀, 중: 컴팩트 검색 (debounced + popover), 우: 관심종목 링크.
//
// 홈의 큰 SearchBox 와 별개로 동작. 결과 클릭 시 popover 가 닫히고 페이지 이동.
// Esc 또는 바깥 클릭으로 popover 닫힘.
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { api, type Symbol } from "../lib/api";
export function TopNav() {
const [q, setQ] = useState("");
const [items, setItems] = useState<Symbol[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
// 디바운스 검색. 입력 비면 결과 클리어.
useEffect(() => {
const term = q.trim();
if (!term) {
setItems([]);
return;
}
setLoading(true);
const h = setTimeout(async () => {
try {
const r = await api.search(term, false, 8, false);
setItems(r.items);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(h);
}, [q]);
// 바깥 클릭으로 popover 닫기.
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, []);
return (
<header className="sticky top-0 z-40 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur">
<div className="mx-auto flex h-12 max-w-5xl items-center gap-4 px-6">
<Link
href="/"
className="shrink-0 text-sm font-semibold tracking-tight text-zinc-100 hover:text-white"
>
Stock Chart
</Link>
<div ref={wrapRef} className="relative flex-1 max-w-md">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="종목 검색"
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-100 outline-none focus:border-zinc-600"
/>
{open && q.trim() && (
<div className="absolute left-0 right-0 top-full mt-1 max-h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 shadow-xl">
{loading && (
<div className="px-3 py-2 text-xs text-zinc-500"></div>
)}
{!loading && items.length === 0 && (
<div className="px-3 py-2 text-xs text-zinc-500"> </div>
)}
<ul className="divide-y divide-zinc-800">
{items.map((it) => (
<li key={it.code}>
<Link
href={`/${it.code}`}
onClick={() => {
setOpen(false);
setQ("");
}}
className="flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-zinc-800/60"
>
<span className="min-w-0 truncate text-zinc-100">
{it.name}
{it.is_seed && (
<span className="ml-1.5 rounded-sm bg-emerald-900/60 px-1 py-0.5 text-[9px] font-semibold text-emerald-300">
SEED
</span>
)}
</span>
<span className="shrink-0 text-zinc-500">
{it.code} · {it.market}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
</div>
<Link
href="/watchlist"
className="shrink-0 text-xs text-amber-300 hover:text-amber-200"
>
</Link>
</div>
</header>
);
}