diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index b3c74e0..1931eb7 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -2,12 +2,14 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { MetricsPanel } from "../../components/MetricsPanel"; import { NewsList } from "../../components/NewsList"; import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; import { RelatedStocks } from "../../components/RelatedStocks"; +import { StarButton } from "../../components/StarButton"; import { StockChart } from "../../components/StockChart"; import { SymbolSidebar } from "../../components/SymbolSidebar"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; @@ -89,10 +91,21 @@ export default function CodePage({ params }: { params: { code: string } }) { return (
- - ← 검색 - - setPeriod(id)} /> +
+ + ← 검색 + + + 관심종목 + +
+
+ + setPeriod(id)} /> +
{err &&
차트 로딩 실패: {err}
} @@ -129,6 +142,9 @@ export default function CodePage({ params }: { params: { code: string } }) { +
+ +
)}
diff --git a/web/app/page.tsx b/web/app/page.tsx index 445ca8d..08f4e83 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { IndicesPanel } from "../components/IndicesPanel"; import { MarketsOverview } from "../components/MarketsOverview"; import { SearchBox } from "../components/SearchBox"; @@ -7,11 +8,21 @@ import { ThemeTiles } from "../components/ThemeTiles"; export default function HomePage() { return (
-

종목 검색

-

- 종목을 골라 차트를 보고, 예상차트 보기 버튼으로 - 15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다. -

+
+
+

종목 검색

+

+ 종목을 골라 차트를 보고, 예상차트 보기 버튼으로 + 15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다. +

+
+ + ★ 관심종목 + +
diff --git a/web/app/watchlist/page.tsx b/web/app/watchlist/page.tsx new file mode 100644 index 0000000..c95b265 --- /dev/null +++ b/web/app/watchlist/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +// /watchlist — localStorage 의 관심종목 코드들을 카드 그리드로. +// SEED 타일과 같은 톤. 각 카드는 /api/chart 2일치를 받아 현재가/전일대비를 채움. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api } from "../../lib/api"; +import { watchlist } from "../../lib/watchlist"; + +type Tile = { + code: string; + name: string; + market: string; + current: number | null; + prev: number | null; +}; + +export default function WatchlistPage() { + const [codes, setCodes] = useState([]); + const [tiles, setTiles] = useState([]); + const [loading, setLoading] = useState(true); + const [hydrated, setHydrated] = useState(false); + + // localStorage 구독 — 다른 탭/페이지에서 추가/제거해도 즉시 반영. + useEffect(() => { + setHydrated(true); + const refresh = () => setCodes(watchlist.get()); + refresh(); + return watchlist.subscribe(refresh); + }, []); + + useEffect(() => { + if (!hydrated) return; + if (codes.length === 0) { + setTiles([]); + setLoading(false); + return; + } + let alive = true; + setLoading(true); + Promise.allSettled( + codes.map(async (code) => { + const c = await api.getChart(code, 5, "1d"); + const valid = c.ohlcv.filter((p) => p.close != null); + const last = valid[valid.length - 1] ?? null; + const prev = valid.length >= 2 ? valid[valid.length - 2] : null; + return { + code, + name: c.name, + market: c.market, + current: last?.close ?? null, + prev: prev?.close ?? null, + }; + }), + ).then((settled) => { + if (!alive) return; + const next: Tile[] = settled.map((r, i) => { + if (r.status === "fulfilled") return r.value; + return { + code: codes[i], + name: codes[i], + market: "—", + current: null, + prev: null, + }; + }); + setTiles(next); + setLoading(false); + }); + return () => { + alive = false; + }; + }, [codes, hydrated]); + + return ( +
+
+ + ← 홈 + + {codes.length} 종목 +
+ +

관심종목

+

+ 브라우저 저장소에 보관됩니다. 로그인 없이 같은 브라우저에서 유지. +

+ +
+ {!hydrated || loading ? ( +
+ 관심종목 로딩 중… +
+ ) : codes.length === 0 ? ( +
+ 아직 관심종목이 없습니다. +
+ 종목 페이지에서 ☆ 관심 버튼을 눌러 추가하세요. +
+ ) : ( +
+ {tiles.map((t) => ( + + ))} +
+ )} +
+
+ ); +} + +function TileCard({ t }: { t: Tile }) { + const change = + t.current != null && t.prev != null ? t.current - t.prev : null; + const pct = + change != null && t.prev != null && t.prev !== 0 + ? (change / t.prev) * 100 + : null; + const tone = + change == null || change === 0 + ? "text-zinc-400" + : change > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( +
+ +
{t.name}
+
+ {t.code} · {t.market} +
+
+ {t.current != null + ? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} +
+
+ {change == null + ? " " + : `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, { + maximumFractionDigits: 0, + })}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`} +
+ + +
+ ); +} diff --git a/web/components/DisclosuresPanel.tsx b/web/components/DisclosuresPanel.tsx new file mode 100644 index 0000000..304f74d --- /dev/null +++ b/web/components/DisclosuresPanel.tsx @@ -0,0 +1,92 @@ +"use client"; + +// 공시(DART) 패널 — news 테이블의 source='dart' 만 골라서 카드 리스트로. +// 별도 백엔드 엔드포인트는 안 만들고 기존 /api/news/{code}?source=dart 재사용. + +import { useEffect, useState } from "react"; +import { api, type NewsResponse } from "../lib/api"; + +export function DisclosuresPanel({ code }: { code: string }) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + api + .news(code, 10, "dart") + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, [code]); + + return ( +
+
+
공시 (DART)
+
+ {data ? `최근 ${data.items.length}건` : ""} +
+
+ + {err &&
공시 로딩 실패: {err}
} + {!err && !data &&
공시 로딩 중…
} + {data && data.items.length === 0 && ( +
최근 공시 없음
+ )} + + {data && data.items.length > 0 && ( + + )} +
+ ); +} + +function formatRelative(iso: string): string { + try { + const d = new Date(iso); + const now = Date.now(); + const diffMin = Math.round((now - d.getTime()) / 60000); + if (diffMin < 1) return "방금"; + if (diffMin < 60) return `${diffMin}분 전`; + if (diffMin < 60 * 24) return `${Math.round(diffMin / 60)}시간 전`; + if (diffMin < 60 * 24 * 7) return `${Math.round(diffMin / (60 * 24))}일 전`; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } catch { + return iso; + } +} + +function pad(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} diff --git a/web/components/StarButton.tsx b/web/components/StarButton.tsx new file mode 100644 index 0000000..2152f20 --- /dev/null +++ b/web/components/StarButton.tsx @@ -0,0 +1,39 @@ +"use client"; + +// 관심종목 토글 버튼. 종목 페이지 PriceHero 옆에 들어감. +// localStorage 만 건드리므로 백엔드 호출 없음. + +import { useEffect, useState } from "react"; +import { watchlist } from "../lib/watchlist"; + +export function StarButton({ code, name }: { code: string; name?: string }) { + const [active, setActive] = useState(false); + + useEffect(() => { + setActive(watchlist.has(code)); + return watchlist.subscribe(() => setActive(watchlist.has(code))); + }, [code]); + + const toggle = () => { + const now = watchlist.toggle(code); + setActive(now); + }; + + return ( + + ); +} diff --git a/web/lib/watchlist.ts b/web/lib/watchlist.ts new file mode 100644 index 0000000..76c3fd0 --- /dev/null +++ b/web/lib/watchlist.ts @@ -0,0 +1,64 @@ +// 관심종목 — localStorage 기반. 로그인 없이 토스의 '내 종목' 느낌. +// 단순 string[] 직렬화. 다중 탭 동기화는 'storage' 이벤트로 처리. + +const KEY = "stockchart.watchlist"; + +function read(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return []; + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((s) => typeof s === "string") : []; + } catch { + return []; + } +} + +function write(codes: string[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(KEY, JSON.stringify(codes)); + // 같은 탭은 storage 이벤트가 안 떠서 수동 dispatch. + window.dispatchEvent(new CustomEvent("watchlist:change")); +} + +export const watchlist = { + get(): string[] { + return read(); + }, + has(code: string): boolean { + return read().includes(code); + }, + add(code: string): void { + const cur = read(); + if (cur.includes(code)) return; + write([code, ...cur]); + }, + remove(code: string): void { + const cur = read(); + write(cur.filter((c) => c !== code)); + }, + toggle(code: string): boolean { + const cur = read(); + if (cur.includes(code)) { + write(cur.filter((c) => c !== code)); + return false; + } + write([code, ...cur]); + return true; + }, + // 컴포넌트가 변경 구독. unsubscribe 함수 반환. + subscribe(cb: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const onStorage = (e: StorageEvent) => { + if (e.key === KEY) cb(); + }; + const onCustom = () => cb(); + window.addEventListener("storage", onStorage); + window.addEventListener("watchlist:change", onCustom as EventListener); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("watchlist:change", onCustom as EventListener); + }; + }, +};