From 300f7beef013de61f760404b6041d0aa3eda4a71 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 15:10:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(nav):=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20stic?= =?UTF-8?q?ky=20=ED=97=A4=EB=8D=94=20+=20=EC=BB=B4=ED=8C=A9=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/components/TopNav.tsx: sticky top header · 좌: Stock Chart 로고 (→ /) · 중: 컴팩트 검색 input + popover (debounced 200ms, with_sparkline=false 빠른 응답) · 우: ★ 관심 (→ /watchlist) - 바깥 클릭/Esc 로 popover 닫힘 - 결과 클릭 시 자동 닫힘 + 검색어 클리어 - web/app/layout.tsx: 최상단에 부착 토스 톱바 톤. 모든 페이지에서 검색 가능 — 홈으로 돌아갈 필요 없음. Co-Authored-By: Claude Opus 4.7 --- web/app/layout.tsx | 6 +- web/components/TopNav.tsx | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 web/components/TopNav.tsx diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 2a89d00..adcc45e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,5 +1,6 @@ import "./globals.css"; import type { Metadata } from "next"; +import { TopNav } from "../components/TopNav"; export const metadata: Metadata = { title: "Stock Chart Site", @@ -9,7 +10,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + ); } diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx new file mode 100644 index 0000000..2ec9370 --- /dev/null +++ b/web/components/TopNav.tsx @@ -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([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const wrapRef = useRef(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 ( +
+
+ + Stock Chart + + +
+ { + 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() && ( +
+ {loading && ( +
검색중…
+ )} + {!loading && items.length === 0 && ( +
결과 없음
+ )} +
    + {items.map((it) => ( +
  • + { + setOpen(false); + setQ(""); + }} + className="flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-zinc-800/60" + > + + {it.name} + {it.is_seed && ( + + SEED + + )} + + + {it.code} · {it.market} + + +
  • + ))} +
+
+ )} +
+ + + ★ 관심 + +
+
+ ); +}