feat(themes): 테마 인덱스/상세 (11 카테고리 · 111 종목)
- seed/themes.py: 반도체/2차전지/바이오/게임/엔터/자동차/금융/조선·방산/플랫폼·AI/유통·소비재/에너지 정적 매핑
- /api/themes (인덱스) + /api/themes/{slug} (종목 카드 + 최신 종가/등락)
- 홈에 ThemeTiles 그리드, /themes/{slug} 상세 페이지
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
111
backend/app/api/themes.py
Normal file
111
backend/app/api/themes.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""테마/카테고리 API.
|
||||||
|
|
||||||
|
토스 '주식 골라보기' 의 테마 그리드와 비슷한 결.
|
||||||
|
정적 매핑(seed/themes.py)을 베이스로, DB 에서 최신 종가/등락만 채워 응답.
|
||||||
|
|
||||||
|
GET /api/themes
|
||||||
|
- 모든 테마 인덱스 (slug/name/description/code_count)
|
||||||
|
|
||||||
|
GET /api/themes/{slug}
|
||||||
|
?limit=20
|
||||||
|
- 해당 테마의 종목 카드 (종목명/시장/종가/등락률). 거래량 기준 정렬.
|
||||||
|
- ohlcv_daily 가 아직 안 채워져 있으면 close/pct_change 가 NULL.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db.connection import get_engine
|
||||||
|
from app.seed.themes import THEMES, theme_by_slug, themes_index
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/themes", tags=["themes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_themes() -> dict:
|
||||||
|
return {"items": themes_index(), "total": len(THEMES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}")
|
||||||
|
def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict:
|
||||||
|
th = theme_by_slug(slug)
|
||||||
|
if th is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"unknown theme: {slug}")
|
||||||
|
|
||||||
|
codes = list(th.codes)[:limit]
|
||||||
|
if not codes:
|
||||||
|
return {
|
||||||
|
"slug": th.slug,
|
||||||
|
"name": th.name,
|
||||||
|
"description": th.description,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
sql = text(
|
||||||
|
"""
|
||||||
|
WITH last2 AS (
|
||||||
|
SELECT DISTINCT date FROM ohlcv_daily ORDER BY date DESC LIMIT 2
|
||||||
|
),
|
||||||
|
bounds AS (SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last2),
|
||||||
|
latest AS (
|
||||||
|
SELECT o.code, o.close AS close0, o.volume AS vol0
|
||||||
|
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d0
|
||||||
|
),
|
||||||
|
prev AS (
|
||||||
|
SELECT o.code, o.close AS close1
|
||||||
|
FROM ohlcv_daily o, bounds WHERE o.date = bounds.d1
|
||||||
|
)
|
||||||
|
SELECT s.code, s.name, s.market,
|
||||||
|
l.close0 AS close,
|
||||||
|
p.close1 AS prev_close,
|
||||||
|
l.vol0 AS volume,
|
||||||
|
CASE WHEN p.close1 > 0 THEN (l.close0 - p.close1) / p.close1 * 100 ELSE NULL END AS pct_change
|
||||||
|
FROM symbols s
|
||||||
|
LEFT JOIN latest l ON l.code = s.code
|
||||||
|
LEFT JOIN prev p ON p.code = s.code
|
||||||
|
WHERE s.code = ANY(:codes)
|
||||||
|
ORDER BY vol0 DESC NULLS LAST, s.name ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
eng = get_engine()
|
||||||
|
with eng.connect() as conn:
|
||||||
|
rows = conn.execute(sql, {"codes": codes}).all()
|
||||||
|
|
||||||
|
# symbols 테이블에 시드만 있고 codes 일부가 누락된 경우, 그래도 코드/이름 자리는 비워서 카드 표시.
|
||||||
|
by_code = {r[0]: r for r in rows}
|
||||||
|
items: list[dict] = []
|
||||||
|
for c in codes:
|
||||||
|
r = by_code.get(c)
|
||||||
|
if r is None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"code": c,
|
||||||
|
"name": c, # symbols 미등재 — 코드로 노출
|
||||||
|
"market": None,
|
||||||
|
"close": None,
|
||||||
|
"prev_close": None,
|
||||||
|
"volume": None,
|
||||||
|
"pct_change": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"code": r[0],
|
||||||
|
"name": r[1],
|
||||||
|
"market": r[2],
|
||||||
|
"close": float(r[3]) if r[3] is not None else None,
|
||||||
|
"prev_close": float(r[4]) if r[4] is not None else None,
|
||||||
|
"volume": int(r[5]) if r[5] is not None else None,
|
||||||
|
"pct_change": float(r[6]) if r[6] is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slug": th.slug,
|
||||||
|
"name": th.name,
|
||||||
|
"description": th.description,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ from app.api.news import router as news_router
|
|||||||
from app.api.predict import router as predict_router
|
from app.api.predict import router as predict_router
|
||||||
from app.api.refresh import router as refresh_router
|
from app.api.refresh import router as refresh_router
|
||||||
from app.api.symbols import router as symbols_router
|
from app.api.symbols import router as symbols_router
|
||||||
|
from app.api.themes import router as themes_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.connection import get_engine, ping as db_ping
|
from app.db.connection import get_engine, ping as db_ping
|
||||||
from app.fetch import dart as dart_mod
|
from app.fetch import dart as dart_mod
|
||||||
@@ -104,6 +105,7 @@ app.include_router(predict_router)
|
|||||||
app.include_router(metrics_router)
|
app.include_router(metrics_router)
|
||||||
app.include_router(news_router)
|
app.include_router(news_router)
|
||||||
app.include_router(markets_router)
|
app.include_router(markets_router)
|
||||||
|
app.include_router(themes_router)
|
||||||
|
|
||||||
|
|
||||||
def _resolved_device() -> str:
|
def _resolved_device() -> str:
|
||||||
|
|||||||
231
backend/app/seed/themes.py
Normal file
231
backend/app/seed/themes.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""테마/카테고리 → 종목코드 정적 매핑.
|
||||||
|
|
||||||
|
토스 '주식 골라보기' 의 테마 분류와 비슷한 결을 흉내. 종목/테마 페어링은 공개
|
||||||
|
정보 기준이며, 시장 사정에 따라 합병/분할/사명변경 등으로 stale 될 수 있다.
|
||||||
|
KRX 정상화 + sector 컬럼 채우기가 끝나면 DB-driven 분류로 전환 가능.
|
||||||
|
|
||||||
|
각 테마는 KRX_STATIC_MAJORS 안의 코드 위주 — symbols 테이블에 무조건 존재해
|
||||||
|
검색/카드 표시가 깨지지 않음.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Theme:
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
codes: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
THEMES: tuple[Theme, ...] = (
|
||||||
|
Theme(
|
||||||
|
slug="semiconductor",
|
||||||
|
name="반도체",
|
||||||
|
description="메모리·파운드리·장비 핵심.",
|
||||||
|
codes=(
|
||||||
|
"005930", # 삼성전자
|
||||||
|
"000660", # SK하이닉스
|
||||||
|
"042700", # 한미반도체
|
||||||
|
"240810", # 원익IPS
|
||||||
|
"357180", # LX세미콘
|
||||||
|
"403870", # HPSP
|
||||||
|
"039030", # 이오테크닉스
|
||||||
|
"089030", # 테크윙
|
||||||
|
"095610", # 테스
|
||||||
|
"067310", # 하나마이크론
|
||||||
|
"000990", # DB하이텍
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="battery",
|
||||||
|
name="2차전지",
|
||||||
|
description="셀·소재·장비 전반.",
|
||||||
|
codes=(
|
||||||
|
"373220", # LG에너지솔루션
|
||||||
|
"006400", # 삼성SDI
|
||||||
|
"051910", # LG화학
|
||||||
|
"247540", # 에코프로비엠
|
||||||
|
"086520", # 에코프로
|
||||||
|
"066970", # 엘앤에프
|
||||||
|
"003670", # 포스코퓨처엠
|
||||||
|
"121600", # 나노신소재
|
||||||
|
"010130", # 고려아연
|
||||||
|
"213420", # 덕산네오룩스
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="bio",
|
||||||
|
name="바이오·헬스케어",
|
||||||
|
description="제약·바이오시밀러·CDMO.",
|
||||||
|
codes=(
|
||||||
|
"207940", # 삼성바이오로직스
|
||||||
|
"068270", # 셀트리온
|
||||||
|
"091990", # 셀트리온헬스케어
|
||||||
|
"196170", # 알테오젠
|
||||||
|
"145020", # 휴젤
|
||||||
|
"086900", # 메디톡스
|
||||||
|
"069620", # 대웅제약
|
||||||
|
"128940", # 한미약품
|
||||||
|
"000100", # 유한양행
|
||||||
|
"328130", # 루닛
|
||||||
|
"237690", # 에스티팜
|
||||||
|
"141080", # 리가켐바이오
|
||||||
|
"000250", # 삼천당제약
|
||||||
|
"028300", # HLB
|
||||||
|
"078160", # 메디포스트
|
||||||
|
"085660", # 차바이오텍
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="game",
|
||||||
|
name="게임",
|
||||||
|
description="모바일·PC·콘솔 게임사.",
|
||||||
|
codes=(
|
||||||
|
"035720", # 카카오 (게임즈 모회사)
|
||||||
|
"036570", # 엔씨소프트
|
||||||
|
"251270", # 넷마블
|
||||||
|
"263750", # 펄어비스
|
||||||
|
"293490", # 카카오게임즈
|
||||||
|
"112040", # 위메이드
|
||||||
|
"194700", # 네오위즈
|
||||||
|
"078340", # 컴투스
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="entertainment",
|
||||||
|
name="엔터테인먼트",
|
||||||
|
description="K-팝/엔터/플랫폼.",
|
||||||
|
codes=(
|
||||||
|
"352820", # 하이브
|
||||||
|
"035900", # JYP Ent.
|
||||||
|
"041510", # 에스엠
|
||||||
|
"122870", # 와이지엔터테인먼트
|
||||||
|
"067160", # 아프리카TV
|
||||||
|
"376300", # 디어유
|
||||||
|
"035760", # CJ ENM
|
||||||
|
"196300", # 쇼박스
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="auto",
|
||||||
|
name="자동차",
|
||||||
|
description="완성차·부품·타이어.",
|
||||||
|
codes=(
|
||||||
|
"005380", # 현대차
|
||||||
|
"000270", # 기아
|
||||||
|
"012330", # 현대모비스
|
||||||
|
"086280", # 현대글로비스
|
||||||
|
"161390", # 한국타이어앤테크놀로지
|
||||||
|
"000240", # 한국앤컴퍼니
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="finance",
|
||||||
|
name="금융",
|
||||||
|
description="은행·증권·보험·지주.",
|
||||||
|
codes=(
|
||||||
|
"105560", # KB금융
|
||||||
|
"055550", # 신한지주
|
||||||
|
"086790", # 하나금융지주
|
||||||
|
"316140", # 우리금융지주
|
||||||
|
"138040", # 메리츠금융지주
|
||||||
|
"175330", # JB금융지주
|
||||||
|
"138930", # BNK금융지주
|
||||||
|
"024110", # 기업은행
|
||||||
|
"032830", # 삼성생명
|
||||||
|
"088350", # 한화생명
|
||||||
|
"000810", # 삼성화재
|
||||||
|
"005830", # DB손해보험
|
||||||
|
"001450", # 현대해상
|
||||||
|
"071050", # 한국금융지주
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="shipbuilding-defense",
|
||||||
|
name="조선·방산",
|
||||||
|
description="조선·중공업·항공우주·방산.",
|
||||||
|
codes=(
|
||||||
|
"329180", # HD현대중공업
|
||||||
|
"009540", # HD한국조선해양
|
||||||
|
"267260", # HD현대일렉트릭
|
||||||
|
"010620", # 현대미포조선
|
||||||
|
"042660", # 한화오션
|
||||||
|
"010140", # 삼성중공업
|
||||||
|
"012450", # 한화에어로스페이스 (SEED)
|
||||||
|
"047810", # 한국항공우주
|
||||||
|
"064350", # 현대로템
|
||||||
|
"267250", # HD현대
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="platform-ai",
|
||||||
|
name="플랫폼·AI",
|
||||||
|
description="검색·메신저·콘텐츠·로봇.",
|
||||||
|
codes=(
|
||||||
|
"035420", # NAVER
|
||||||
|
"035720", # 카카오
|
||||||
|
"277810", # 레인보우로보틱스
|
||||||
|
"304100", # 솔트룩스
|
||||||
|
"388790", # 케이엠더블유
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="consumer-retail",
|
||||||
|
name="유통·소비재",
|
||||||
|
description="대형마트·백화점·식음료·뷰티.",
|
||||||
|
codes=(
|
||||||
|
"139480", # 이마트
|
||||||
|
"023530", # 롯데쇼핑
|
||||||
|
"069960", # 현대백화점
|
||||||
|
"004170", # 신세계
|
||||||
|
"282330", # BGF리테일
|
||||||
|
"007070", # GS리테일
|
||||||
|
"097950", # CJ제일제당
|
||||||
|
"271560", # 오리온
|
||||||
|
"005180", # 빙그레
|
||||||
|
"049770", # 동원F&B
|
||||||
|
"005300", # 롯데칠성
|
||||||
|
"000080", # 하이트진로
|
||||||
|
"002790", # 아모레G
|
||||||
|
"090430", # 아모레퍼시픽
|
||||||
|
"008770", # 호텔신라
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Theme(
|
||||||
|
slug="energy-utility",
|
||||||
|
name="에너지·유틸리티",
|
||||||
|
description="전력·정유·가스·풍력.",
|
||||||
|
codes=(
|
||||||
|
"015760", # 한국전력공사
|
||||||
|
"017670", # SK텔레콤 (필수통신 묶음)
|
||||||
|
"030200", # KT
|
||||||
|
"010950", # S-Oil
|
||||||
|
"047050", # 포스코인터내셔널
|
||||||
|
"036460", # 한국가스공사 (SEED)
|
||||||
|
"034020", # 두산에너빌리티 (SEED)
|
||||||
|
"088980", # 맥쿼리인프라
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def themes_index() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": t.slug,
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"code_count": len(t.codes),
|
||||||
|
}
|
||||||
|
for t in THEMES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def theme_by_slug(slug: str) -> Theme | None:
|
||||||
|
for t in THEMES:
|
||||||
|
if t.slug == slug:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MarketsOverview } from "../components/MarketsOverview";
|
import { MarketsOverview } from "../components/MarketsOverview";
|
||||||
import { SearchBox } from "../components/SearchBox";
|
import { SearchBox } from "../components/SearchBox";
|
||||||
import { SeedTiles } from "../components/SeedTiles";
|
import { SeedTiles } from "../components/SeedTiles";
|
||||||
|
import { ThemeTiles } from "../components/ThemeTiles";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +20,10 @@ export default function HomePage() {
|
|||||||
<SeedTiles />
|
<SeedTiles />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<ThemeTiles />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<MarketsOverview />
|
<MarketsOverview />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
97
web/app/themes/[slug]/page.tsx
Normal file
97
web/app/themes/[slug]/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 테마 상세 페이지: /themes/{slug}.
|
||||||
|
// 카드 그리드로 해당 테마 종목을 나열. close/pct_change 없으면 자리만 표시.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type ThemeDetailResponse } from "../../../lib/api";
|
||||||
|
|
||||||
|
export default function ThemePage({ params }: { params: { slug: string } }) {
|
||||||
|
const { slug } = params;
|
||||||
|
const [data, setData] = useState<ThemeDetailResponse | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setErr(null);
|
||||||
|
setData(null);
|
||||||
|
api
|
||||||
|
.themeDetail(slug, 60)
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setData(r);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||||
|
← 홈
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="text-sm text-red-400">테마 로딩 실패: {err}</div>}
|
||||||
|
{!data && !err && <div className="text-sm text-zinc-500">테마 로딩 중…</div>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-50">{data.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">{data.description}</p>
|
||||||
|
<div className="mt-1 text-[11px] text-zinc-600">
|
||||||
|
{data.items.length} 종목
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.items.map((m) => {
|
||||||
|
const pct = m.pct_change;
|
||||||
|
const tone =
|
||||||
|
pct == null || pct === 0
|
||||||
|
? "text-zinc-400"
|
||||||
|
: pct > 0
|
||||||
|
? "text-rose-400"
|
||||||
|
: "text-sky-400";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={m.code}
|
||||||
|
href={`/${m.code}`}
|
||||||
|
className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="min-w-0 truncate text-sm font-medium text-zinc-100">
|
||||||
|
{m.name}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-[11px] text-zinc-500">
|
||||||
|
{m.market ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline justify-between text-xs tabular-nums">
|
||||||
|
<span className="text-zinc-200">
|
||||||
|
{m.close != null
|
||||||
|
? m.close.toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
<span className={tone}>
|
||||||
|
{pct != null
|
||||||
|
? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-zinc-600">{m.code}</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
web/components/ThemeTiles.tsx
Normal file
69
web/components/ThemeTiles.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 홈에 들어가는 '테마로 골라보기' 그리드.
|
||||||
|
// /api/themes 인덱스를 받아 카드로 펼치고, 카드 클릭 시 /themes/{slug} 로 이동.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type ThemesIndexResponse } from "../lib/api";
|
||||||
|
|
||||||
|
export function ThemeTiles() {
|
||||||
|
const [data, setData] = useState<ThemesIndexResponse | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api
|
||||||
|
.themesIndex()
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setData(r);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (err)
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||||
|
테마 로딩 실패: {err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!data)
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 text-xs text-zinc-500">
|
||||||
|
테마 로딩 중…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">테마로 골라보기</h2>
|
||||||
|
<span className="text-[11px] text-zinc-500">{data.total} 카테고리</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{data.items.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.slug}
|
||||||
|
href={`/themes/${t.slug}`}
|
||||||
|
className="group rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2.5 transition hover:border-zinc-700 hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-zinc-100 group-hover:text-rose-300">
|
||||||
|
{t.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-zinc-500">
|
||||||
|
{t.description}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-zinc-600">
|
||||||
|
{t.code_count} 종목
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -186,6 +186,25 @@ export type RelatedResponse = {
|
|||||||
items: Mover[];
|
items: Mover[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThemeIndex = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
code_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemesIndexResponse = {
|
||||||
|
items: ThemeIndex[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeDetailResponse = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
items: Mover[];
|
||||||
|
};
|
||||||
|
|
||||||
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -239,4 +258,9 @@ export const api = {
|
|||||||
getJson<RelatedResponse>(
|
getJson<RelatedResponse>(
|
||||||
`/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`,
|
`/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`,
|
||||||
),
|
),
|
||||||
|
themesIndex: () => getJson<ThemesIndexResponse>(`/api/themes`),
|
||||||
|
themeDetail: (slug: string, limit = 30) =>
|
||||||
|
getJson<ThemeDetailResponse>(
|
||||||
|
`/api/themes/${encodeURIComponent(slug)}?limit=${limit}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user