feat(ui): 관심종목(localStorage) + DART 공시 패널
- web/lib/watchlist.ts: KEY=stockchart.watchlist, storage+custom event 구독 - StarButton: 종목 페이지 헤더에서 ☆/★ 토글 - /watchlist 페이지: 카드 그리드 + 현재가/전일대비 + ✕ 제거 - DisclosuresPanel: 기존 /api/news?source=dart 재사용 (백엔드 신설 없음) - 종목 페이지 하단에 공시 패널 부착, 홈에 ★ 관심종목 링크 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,11 +91,22 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색
|
||||
</Link>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
관심종목
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StarButton code={code} name={chart?.name} />
|
||||
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-4 text-sm text-red-400">차트 로딩 실패: {err}</div>}
|
||||
|
||||
@@ -129,6 +142,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
<MetricsPanel code={code} />
|
||||
<NewsList code={code} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<DisclosuresPanel code={code} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<main className="mx-auto max-w-5xl px-6 py-12">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-50">종목 검색</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
종목을 골라 차트를 보고, <b className="text-rose-300">예상차트 보기</b> 버튼으로
|
||||
15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="shrink-0 rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300 hover:bg-amber-500/20"
|
||||
>
|
||||
★ 관심종목
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SearchBox autoFocus />
|
||||
|
||||
159
web/app/watchlist/page.tsx
Normal file
159
web/app/watchlist/page.tsx
Normal file
@@ -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<string[]>([]);
|
||||
const [tiles, setTiles] = useState<Tile[]>([]);
|
||||
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 (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 홈
|
||||
</Link>
|
||||
<span className="text-[11px] text-zinc-500">{codes.length} 종목</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-zinc-50">관심종목</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
브라우저 저장소에 보관됩니다. 로그인 없이 같은 브라우저에서 유지.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
{!hydrated || loading ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||
관심종목 로딩 중…
|
||||
</div>
|
||||
) : codes.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-6 text-center text-sm text-zinc-500">
|
||||
아직 관심종목이 없습니다.
|
||||
<br />
|
||||
종목 페이지에서 <b className="text-amber-300">☆ 관심</b> 버튼을 눌러 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{tiles.map((t) => (
|
||||
<TileCard key={t.code} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={`/${t.code}`}
|
||||
className="block rounded-md border border-zinc-800 bg-zinc-900/40 p-3 transition hover:border-zinc-600"
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-zinc-100">{t.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-zinc-500">
|
||||
{t.code} · {t.market}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tabular-nums text-zinc-100">
|
||||
{t.current != null
|
||||
? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
: "—"}
|
||||
</div>
|
||||
<div className={`text-[11px] tabular-nums ${tone}`}>
|
||||
{change == null
|
||||
? " "
|
||||
: `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`}
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchlist.remove(t.code)}
|
||||
title="관심종목에서 제거"
|
||||
className="absolute right-1.5 top-1.5 rounded-full bg-zinc-900/80 px-1.5 py-0.5 text-[10px] text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
web/components/DisclosuresPanel.tsx
Normal file
92
web/components/DisclosuresPanel.tsx
Normal file
@@ -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<NewsResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<div className="text-sm font-medium text-zinc-200">공시 (DART)</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{data ? `최근 ${data.items.length}건` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="text-xs text-red-400">공시 로딩 실패: {err}</div>}
|
||||
{!err && !data && <div className="text-xs text-zinc-500">공시 로딩 중…</div>}
|
||||
{data && data.items.length === 0 && (
|
||||
<div className="text-xs text-zinc-500">최근 공시 없음</div>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{data.items.map((n, i) => (
|
||||
<li key={i}>
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-transparent p-2 transition hover:border-zinc-700 hover:bg-zinc-800/40"
|
||||
>
|
||||
<div className="text-sm leading-snug text-zinc-100 line-clamp-2">
|
||||
{n.title}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="rounded-sm bg-emerald-900/50 px-1.5 py-0.5 font-semibold text-emerald-300">
|
||||
DART
|
||||
</span>
|
||||
{n.published_at && (
|
||||
<span className="text-zinc-500">
|
||||
{formatRelative(n.published_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
39
web/components/StarButton.tsx
Normal file
39
web/components/StarButton.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={active}
|
||||
title={active ? "관심종목에서 제거" : "관심종목에 추가"}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
active
|
||||
? "border-amber-500/60 bg-amber-500/10 text-amber-300"
|
||||
: "border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{active ? "★" : "☆"}</span>
|
||||
<span>{active ? "관심" : "관심"}</span>
|
||||
{name && <span className="sr-only">{name}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
64
web/lib/watchlist.ts
Normal file
64
web/lib/watchlist.ts
Normal file
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user