- 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>
93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
"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}`;
|
|
}
|