+ {item.title} +
++ {item.summary} +
+diff --git a/src/api.js b/src/api.js index 51d379c..6f20c45 100644 --- a/src/api.js +++ b/src/api.js @@ -17,6 +17,22 @@ export async function apiDelete(path) { return res.json(); } +export async function apiPost(path, body) { + const res = await fetch(path, { + method: "POST", + headers: { + "Accept": "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + export function getLatest() { return apiGet("/api/lotto/latest"); } @@ -41,3 +57,15 @@ export function getHistory(limit = 30, offset = 0) { export function deleteHistory(id) { return apiDelete(`/api/history/${id}`); } + +export function getStockNews(limit = 20) { + return apiGet(`/api/stock/news?limit=${limit}`); +} + +export function triggerStockScrap() { + return apiPost("/api/admin/stock/scrap"); +} + +export function getStockHealth() { + return apiGet("/api/stock/health"); +} diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css new file mode 100644 index 0000000..4b53c7f --- /dev/null +++ b/src/pages/stock/Stock.css @@ -0,0 +1,286 @@ +.stock { + display: grid; + gap: 28px; +} + +.stock-header { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 24px; + align-items: center; +} + +.stock-kicker { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 12px; + color: var(--accent); + margin: 0 0 10px; +} + +.stock-header h1 { + margin: 0 0 12px; + font-family: var(--font-display); + font-size: clamp(30px, 4vw, 40px); +} + +.stock-sub { + margin: 0; + color: var(--muted); +} + +.stock-actions { + display: flex; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; +} + +.stock-card { + border: 1px solid var(--line); + border-radius: 20px; + padding: 20px; + background: var(--surface); + display: grid; + gap: 14px; +} + +.stock-card__title { + margin: 0; + font-weight: 600; +} + +.stock-status { + display: grid; + gap: 10px; + color: var(--muted); + font-size: 13px; +} + +.stock-status > div { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.stock-status strong { + color: var(--text); +} + +.stock-status__note { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.stock-pill { + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + border: 1px solid var(--line); +} + +.stock-pill.is-ok { + border-color: rgba(106, 220, 187, 0.6); + color: #b5f0dd; +} + +.stock-pill.is-warn { + border-color: rgba(245, 200, 115, 0.6); + color: #f5d28a; +} + +.stock-pill.is-unknown { + color: var(--muted); +} + +.stock-error { + margin: 0; + color: #f9b6b1; + border: 1px solid rgba(249, 182, 177, 0.4); + border-radius: 14px; + padding: 12px; + background: rgba(249, 182, 177, 0.1); +} + +.stock-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 18px; +} + +.stock-panel { + border: 1px solid var(--line); + background: var(--surface); + border-radius: 24px; + padding: 20px; + display: grid; + gap: 16px; +} + +.stock-panel--wide { + grid-column: 1 / -1; +} + +.stock-panel__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.stock-panel__eyebrow { + margin: 0 0 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--accent); +} + +.stock-panel__sub { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.stock-panel__actions { + display: flex; + gap: 8px; + align-items: center; +} + +.stock-chip { + font-size: 11px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.stock-snapshot { + display: grid; + gap: 12px; +} + +.stock-snapshot__card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + display: grid; + gap: 6px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-snapshot__card p { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.stock-snapshot__card strong { + font-size: 20px; +} + +.stock-snapshot__card span { + color: var(--muted); + font-size: 12px; +} + +.stock-schedule { + display: grid; + gap: 12px; + font-size: 13px; + color: var(--muted); +} + +.stock-schedule strong { + color: var(--text); +} + +.stock-filter { + display: grid; + gap: 12px; + color: var(--muted); + font-size: 13px; +} + +.stock-filter label { + display: grid; + gap: 8px; +} + +.stock-filter select { + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.25); + color: var(--text); + border-radius: 12px; + padding: 10px 12px; +} + +.stock-filter__note { + margin: 0; + font-size: 12px; +} + +.stock-news { + display: grid; + gap: 14px; +} + +.stock-news__item { + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 10px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-news__title { + margin: 0; + font-weight: 600; + font-size: 16px; +} + +.stock-news__summary { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.stock-news__meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; + color: var(--muted); + align-items: center; +} + +.stock-news__meta a { + color: var(--accent); +} + +.stock-empty { + margin: 0; + color: var(--muted); +} + +@media (max-width: 900px) { + .stock-header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .stock-panel { + padding: 16px; + } +} diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx new file mode 100644 index 0000000..3d9300b --- /dev/null +++ b/src/pages/stock/Stock.jsx @@ -0,0 +1,290 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + getStockHealth, + getStockNews, + triggerStockScrap, +} from '../../api'; +import './Stock.css'; + +const formatDate = (value) => value ?? '-'; + +const toDateValue = (value) => { + if (!value) return null; + const normalized = value.replace(' ', 'T').replace(/\./g, '-'); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const getLatestBy = (items, key) => { + const filtered = items + .map((item) => ({ ...item, __date: toDateValue(item?.[key]) })) + .filter((item) => item.__date); + if (!filtered.length) return null; + filtered.sort((a, b) => b.__date - a.__date); + return filtered[0]?.[key] ?? null; +}; + +const Stock = () => { + const [news, setNews] = useState([]); + const [limit, setLimit] = useState(20); + const [loading, setLoading] = useState(false); + const [scraping, setScraping] = useState(false); + const [error, setError] = useState(''); + const [health, setHealth] = useState({ + status: 'unknown', + message: '', + }); + + const latestCrawled = useMemo( + () => getLatestBy(news, 'crawled_at'), + [news] + ); + const latestPublished = useMemo( + () => getLatestBy(news, 'pub_date'), + [news] + ); + + const loadNews = async () => { + setLoading(true); + setError(''); + try { + const data = await getStockNews(limit); + setNews(Array.isArray(data) ? data : []); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setLoading(false); + } + }; + + const loadHealth = async () => { + try { + const data = await getStockHealth(); + setHealth({ + status: data?.ok ? 'ok' : 'warn', + message: data?.message ?? '', + }); + } catch (err) { + setHealth({ + status: 'unknown', + message: err?.message ?? '', + }); + } + }; + + const onScrap = async () => { + setScraping(true); + setError(''); + try { + const result = await triggerStockScrap(); + if (!result?.ok) { + throw new Error('스크랩 요청이 실패했습니다.'); + } + await loadNews(); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setScraping(false); + } + }; + + useEffect(() => { + loadNews(); + }, [limit]); + + useEffect(() => { + loadHealth(); + }, []); + + return ( +
Market Lab
++ 매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전 + 빠르게 흐름을 파악할 수 있게 구성했습니다. +
+오늘의 상태
+{health.message}
+ ) : null} +{error}
: null} + +Snapshot
++ 지수/가격 API 연동을 위한 준비 구간입니다. +
+{label}
+ -- + 연동 예정 +Schedule
++ 매일 오전 8시에 자동 스크랩이 실행됩니다. +
+Filter
++ 표시 개수를 조정해 빠르게 훑어볼 수 있습니다. +
++ 최신 기사부터 정렬됩니다. +
+Headlines
++ 스크랩된 뉴스 요약을 바로 확인할 수 있습니다. +
+뉴스를 불러오는 중...
+ ) : news.length === 0 ? ( +표시할 뉴스가 없습니다.
+ ) : ( ++ {item.title} +
++ {item.summary} +
+