Feat/digest ui followup #26
+91
-5
@@ -2,11 +2,15 @@
|
||||
|
||||
엔드포인트:
|
||||
- GET /api/digest/latest : 가장 최근 digest
|
||||
- GET /api/digest/dates : 생성된 digest 날짜 목록 (date picker 용)
|
||||
- GET /api/digest?date=YYYY-MM-DD : 특정 날짜 digest
|
||||
- GET /api/digest?country=KR : 특정 국가만
|
||||
- POST /api/digest/regenerate : 백그라운드 digest 워커 트리거 (auth 필요)
|
||||
|
||||
응답은 country → topic 2-level 구조. country 가 비어있는 경우 응답에서 자동 생략.
|
||||
각 topic 은 article_ids(doc_id) 와 함께 articles([{id, title}]) 를 반환 — title 은 documents
|
||||
배치 조회로 채우며(한 digest 당 1 쿼리), 매칭 없는 id(하드삭제 등)는 title=null 로 둔다
|
||||
(프론트는 "(제목 없음)" 으로 렌더, 빈 링크 금지). article → /documents/{id} 라우팅용.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -23,6 +27,7 @@ from sqlalchemy.orm import selectinload
|
||||
from core.auth import get_current_user, require_admin
|
||||
from core.database import get_session
|
||||
from models.digest import DigestTopic, GlobalDigest
|
||||
from models.document import Document
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -31,11 +36,17 @@ router = APIRouter()
|
||||
# ─── Pydantic 응답 모델 (schemas/ 디렉토리 미사용 → inline 정의) ───
|
||||
|
||||
|
||||
class ArticleRef(BaseModel):
|
||||
id: int
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class TopicResponse(BaseModel):
|
||||
topic_rank: int
|
||||
topic_label: str
|
||||
summary: str
|
||||
article_ids: list[int]
|
||||
articles: list[ArticleRef]
|
||||
article_count: int
|
||||
importance_score: float
|
||||
raw_weight_sum: float
|
||||
@@ -62,21 +73,65 @@ class DigestResponse(BaseModel):
|
||||
countries: list[CountryGroup]
|
||||
|
||||
|
||||
class DigestDateSummary(BaseModel):
|
||||
"""date picker 용 경량 요약 (브리핑 /briefing/dates 와 동형)."""
|
||||
|
||||
digest_date: date_type
|
||||
total_topics: int
|
||||
total_countries: int
|
||||
total_articles: int
|
||||
status: str
|
||||
|
||||
|
||||
# ─── helpers ───
|
||||
|
||||
|
||||
def _build_response(digest: GlobalDigest, country_filter: str | None = None) -> DigestResponse:
|
||||
"""ORM 객체 → DigestResponse. country_filter 가 주어지면 해당 국가만."""
|
||||
def _collect_article_ids(digest: GlobalDigest) -> set[int]:
|
||||
"""digest 의 모든 topic article_ids 를 dedupe 한 set (배치 title 조회용).
|
||||
|
||||
같은 기사가 여러 topic 에 걸리면 중복 id 가 생기므로 set 으로 한 번 줄인다.
|
||||
"""
|
||||
ids: set[int] = set()
|
||||
for t in digest.topics:
|
||||
for aid in t.article_ids or []:
|
||||
try:
|
||||
ids.add(int(aid))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
async def _fetch_titles(session: AsyncSession, ids: set[int]) -> dict[int, str | None]:
|
||||
"""doc_id → title 배치 조회. 매칭 없는 id 는 map 에 부재(호출부가 None 처리)."""
|
||||
if not ids:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
select(Document.id, Document.title).where(Document.id.in_(ids))
|
||||
)
|
||||
return {row.id: row.title for row in result.all()}
|
||||
|
||||
|
||||
def _build_response(
|
||||
digest: GlobalDigest,
|
||||
title_map: dict[int, str | None],
|
||||
country_filter: str | None = None,
|
||||
) -> DigestResponse:
|
||||
"""ORM 객체 → DigestResponse. country_filter 가 주어지면 해당 국가만.
|
||||
|
||||
title_map miss(삭제/아카이브된 문서)는 title=None 으로 — 프론트가 "(제목 없음)" 처리.
|
||||
"""
|
||||
topics_by_country: dict[str, list[TopicResponse]] = {}
|
||||
for t in sorted(digest.topics, key=lambda x: (x.country, x.topic_rank)):
|
||||
if country_filter and t.country != country_filter:
|
||||
continue
|
||||
ids = [int(a) for a in (t.article_ids or [])]
|
||||
topics_by_country.setdefault(t.country, []).append(
|
||||
TopicResponse(
|
||||
topic_rank=t.topic_rank,
|
||||
topic_label=t.topic_label,
|
||||
summary=t.summary,
|
||||
article_ids=list(t.article_ids or []),
|
||||
article_ids=ids,
|
||||
articles=[ArticleRef(id=aid, title=title_map.get(aid)) for aid in ids],
|
||||
article_count=t.article_count,
|
||||
importance_score=t.importance_score,
|
||||
raw_weight_sum=t.raw_weight_sum,
|
||||
@@ -120,6 +175,12 @@ async def _load_digest(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _respond(session: AsyncSession, digest: GlobalDigest, country_filter: str | None = None) -> DigestResponse:
|
||||
"""digest 1건 → article 제목 배치 enrich 후 응답 빌드."""
|
||||
title_map = await _fetch_titles(session, _collect_article_ids(digest))
|
||||
return _build_response(digest, title_map, country_filter=country_filter)
|
||||
|
||||
|
||||
# ─── Routes ───
|
||||
|
||||
|
||||
@@ -132,7 +193,32 @@ async def get_latest(
|
||||
digest = await _load_digest(session, target_date=None)
|
||||
if digest is None:
|
||||
raise HTTPException(status_code=404, detail="아직 생성된 digest 없음")
|
||||
return _build_response(digest)
|
||||
return await _respond(session, digest)
|
||||
|
||||
|
||||
@router.get("/dates", response_model=list[DigestDateSummary])
|
||||
async def list_dates(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
limit: int = Query(default=60, ge=1, le=365, description="최신부터 N개"),
|
||||
):
|
||||
"""생성된 digest 날짜 목록 (date picker 용, 최신 내림차순)."""
|
||||
query = (
|
||||
select(GlobalDigest)
|
||||
.order_by(GlobalDigest.digest_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await session.execute(query)).scalars().all()
|
||||
return [
|
||||
DigestDateSummary(
|
||||
digest_date=g.digest_date,
|
||||
total_topics=g.total_topics,
|
||||
total_countries=g.total_countries,
|
||||
total_articles=g.total_articles,
|
||||
status=g.status,
|
||||
)
|
||||
for g in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=DigestResponse)
|
||||
@@ -150,7 +236,7 @@ async def get_digest(
|
||||
detail=f"digest 없음 (date={date})" if date else "아직 생성된 digest 없음",
|
||||
)
|
||||
country_filter = country.upper() if country else None
|
||||
return _build_response(digest, country_filter=country_filter)
|
||||
return await _respond(session, digest, country_filter=country_filter)
|
||||
|
||||
|
||||
@router.post("/regenerate")
|
||||
|
||||
@@ -282,6 +282,10 @@ async def _lookup_news_source(
|
||||
):
|
||||
return src.country, src.name, src.language
|
||||
|
||||
logger.warning(
|
||||
f"[chunk] news_source 매핑 실패: doc_id={doc.id} ai_sub_group={source_name!r} "
|
||||
f"→ country NULL (news_sources prefix 미일치). 신규 source 또는 RSS category 오염 가능."
|
||||
)
|
||||
return None, source_name, None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 디제스트 국가 코드 → 한국어 이름 + 인라인 SVG 국기.
|
||||
// 이모지 국기 금지(no-emoji 규칙) → 자체 SVG. npm 의존성 0, self-contained.
|
||||
// 실데이터 국가(2026-06-04 기준): KR FR HK US DE JP CN TW IN GB.
|
||||
// SVG 는 viewBox 0 0 24 16. 단순화했으나 식별 가능 수준. 정적 문자열이라 {@html} 안전.
|
||||
|
||||
export type CountryInfo = { ko: string; flag: string };
|
||||
|
||||
const NAMES: Record<string, string> = {
|
||||
KR: '한국',
|
||||
FR: '프랑스',
|
||||
HK: '홍콩',
|
||||
US: '미국',
|
||||
DE: '독일',
|
||||
JP: '일본',
|
||||
CN: '중국',
|
||||
TW: '대만',
|
||||
IN: '인도',
|
||||
GB: '영국',
|
||||
};
|
||||
|
||||
const FLAGS: Record<string, string> = {
|
||||
KR: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><path d="M12 5a3 3 0 0 1 0 6 1.5 1.5 0 0 0 0-3 1.5 1.5 0 0 1 0-3z" fill="#cd2e3a"/><path d="M12 5a3 3 0 0 0 0 6 1.5 1.5 0 0 1 0-3 1.5 1.5 0 0 0 0-3z" fill="#0047a0"/></svg>`,
|
||||
US: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><g fill="#b22234"><rect width="24" height="1.23" y="0"/><rect width="24" height="1.23" y="2.46"/><rect width="24" height="1.23" y="4.92"/><rect width="24" height="1.23" y="7.38"/><rect width="24" height="1.23" y="9.84"/><rect width="24" height="1.23" y="12.3"/><rect width="24" height="1.23" y="14.77"/></g><rect width="10" height="8.6" fill="#3c3b6e"/></svg>`,
|
||||
JP: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><circle cx="12" cy="8" r="4.8" fill="#bc002d"/></svg>`,
|
||||
CN: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#de2910"/><path d="M5 2.4l.86 2.65h2.78l-2.25 1.63.86 2.65L5 7.7 2.75 9.33l.86-2.65L1.36 5.05h2.78z" fill="#ffde00"/><g fill="#ffde00"><circle cx="9.6" cy="2.1" r=".6"/><circle cx="11" cy="3.6" r=".6"/><circle cx="11" cy="5.7" r=".6"/><circle cx="9.6" cy="7.1" r=".6"/></g></svg>`,
|
||||
FR: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="16" fill="#0055a4"/><rect width="8" height="16" x="8" fill="#fff"/><rect width="8" height="16" x="16" fill="#ef4135"/></svg>`,
|
||||
DE: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" y="0" fill="#000"/><rect width="24" height="5.33" y="5.33" fill="#dd0000"/><rect width="24" height="5.34" y="10.66" fill="#ffce00"/></svg>`,
|
||||
HK: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#de2910"/><g fill="#fff" transform="translate(12 8)"><circle cx="0" cy="-3.2" r="1.25"/><circle cx="3.05" cy="-1" r="1.25"/><circle cx="1.88" cy="2.6" r="1.25"/><circle cx="-1.88" cy="2.6" r="1.25"/><circle cx="-3.05" cy="-1" r="1.25"/></g></svg>`,
|
||||
TW: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fe0000"/><rect width="12" height="8" fill="#000095"/><circle cx="6" cy="4" r="2.4" fill="#fff"/><circle cx="6" cy="4" r="1.3" fill="#000095"/></svg>`,
|
||||
IN: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" y="0" fill="#ff9933"/><rect width="24" height="5.33" y="5.33" fill="#fff"/><rect width="24" height="5.34" y="10.66" fill="#138808"/><circle cx="12" cy="8" r="1.9" fill="none" stroke="#000080" stroke-width=".5"/></svg>`,
|
||||
GB: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#012169"/><path d="M0 0L24 16M24 0L0 16" stroke="#fff" stroke-width="3.2"/><path d="M0 0L24 16M24 0L0 16" stroke="#c8102e" stroke-width="1.6"/><path d="M12 0V16M0 8H24" stroke="#fff" stroke-width="5.3"/><path d="M12 0V16M0 8H24" stroke="#c8102e" stroke-width="3.2"/></svg>`,
|
||||
};
|
||||
|
||||
/** 국가 코드 → {한국어 이름, SVG 국기}. 미등록 코드는 ko=코드 그대로, flag='' (호출부가 코드칩 fallback). */
|
||||
export function countryLabel(code: string): CountryInfo {
|
||||
const c = (code || '').toUpperCase();
|
||||
return { ko: NAMES[c] ?? c, flag: FLAGS[c] ?? '' };
|
||||
}
|
||||
|
||||
export function hasFlag(code: string): boolean {
|
||||
return !!FLAGS[(code || '').toUpperCase()];
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, type ApiError } from '$lib/api';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { countryLabel } from '$lib/i18n/countries';
|
||||
|
||||
type ArticleRef = { id: number; title: string | null };
|
||||
type Topic = {
|
||||
topic_rank: number;
|
||||
topic_label: string;
|
||||
summary: string;
|
||||
article_ids: number[];
|
||||
articles: ArticleRef[];
|
||||
article_count: number;
|
||||
importance_score: number;
|
||||
llm_fallback_used: boolean;
|
||||
@@ -28,96 +32,248 @@
|
||||
status: string;
|
||||
countries: Country[];
|
||||
};
|
||||
type DateSummary = {
|
||||
digest_date: string;
|
||||
total_topics: number;
|
||||
total_countries: number;
|
||||
total_articles: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const PREVIEW = 5; // topic 카드에서 기본 노출할 기사 수
|
||||
|
||||
let digest = $state<Digest | null>(null);
|
||||
let dates = $state<DateSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let country = $state<string>('');
|
||||
let loadedDate = $state<string | null>(null); // 어떤 date 파라미터로 로드했는지 (중복 로드 방지)
|
||||
let expanded = $state<Record<number, boolean>>({}); // topic_rank → 기사 펼침
|
||||
|
||||
async function load() {
|
||||
// URL = 단일 진실: ?date=, ?country=
|
||||
let urlDate = $derived($page.url.searchParams.get('date') ?? '');
|
||||
let urlCountry = $derived($page.url.searchParams.get('country') ?? '');
|
||||
|
||||
async function loadDates() {
|
||||
try {
|
||||
dates = await api<DateSummary[]>('/digest/dates');
|
||||
} catch {
|
||||
dates = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDigest(dateStr: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
expanded = {};
|
||||
try {
|
||||
digest = await api<Digest>('/digest/latest');
|
||||
const countries = digest?.countries ?? [];
|
||||
if (!countries.some((c) => c.country === country)) {
|
||||
country = countries[0]?.country ?? '';
|
||||
}
|
||||
const path = dateStr ? `/digest?date=${dateStr}` : '/digest/latest';
|
||||
digest = await api<Digest>(path);
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err && err.status === 404) {
|
||||
digest = null;
|
||||
error = null;
|
||||
return;
|
||||
} else {
|
||||
error = err?.detail ?? (e as Error)?.message ?? '알 수 없는 오류';
|
||||
}
|
||||
error = err?.detail ?? (e as Error)?.message ?? '알 수 없는 오류';
|
||||
} finally {
|
||||
loadedDate = dateStr;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
// URL date 변경(최초 포함) → 로드
|
||||
$effect(() => {
|
||||
const d = urlDate;
|
||||
if (d !== loadedDate) loadDigest(d);
|
||||
});
|
||||
|
||||
let tabs = $derived(
|
||||
(digest?.countries ?? []).map((c) => ({ id: c.country, label: c.country })),
|
||||
);
|
||||
onMount(loadDates);
|
||||
|
||||
// 현재 country: URL 우선, 없으면(또는 해당 날짜에 부재) 첫 국가로 graceful fallback
|
||||
let activeCountry = $derived.by(() => {
|
||||
const list = digest?.countries ?? [];
|
||||
if (list.some((c) => c.country === urlCountry)) return urlCountry;
|
||||
return list[0]?.country ?? '';
|
||||
});
|
||||
let topics = $derived(
|
||||
digest?.countries?.find((c) => c.country === country)?.topics ?? [],
|
||||
digest?.countries?.find((c) => c.country === activeCountry)?.topics ?? [],
|
||||
);
|
||||
|
||||
// 날짜 네비게이션 (dates = 최신 내림차순)
|
||||
let currentDate = $derived(digest?.digest_date ?? urlDate ?? '');
|
||||
let dateIdx = $derived(dates.findIndex((d) => d.digest_date === currentDate));
|
||||
let hasOlder = $derived(dateIdx >= 0 && dateIdx < dates.length - 1);
|
||||
let hasNewer = $derived(dateIdx > 0);
|
||||
|
||||
function setParams(next: { date?: string | null; country?: string | null }) {
|
||||
const p = new URLSearchParams($page.url.searchParams);
|
||||
if ('date' in next) next.date ? p.set('date', next.date) : p.delete('date');
|
||||
if ('country' in next) next.country ? p.set('country', next.country) : p.delete('country');
|
||||
const qs = p.toString();
|
||||
goto(`/digest${qs ? '?' + qs : ''}`, { noScroll: true, keepFocus: true });
|
||||
}
|
||||
function pickDate(d: string) {
|
||||
setParams({ date: d });
|
||||
}
|
||||
function pickCountry(c: string) {
|
||||
setParams({ country: c });
|
||||
}
|
||||
function older() {
|
||||
if (hasOlder) pickDate(dates[dateIdx + 1].digest_date);
|
||||
}
|
||||
function newer() {
|
||||
if (hasNewer) pickDate(dates[dateIdx - 1].digest_date);
|
||||
}
|
||||
function onSelectDate(e: Event) {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
pickDate(v);
|
||||
}
|
||||
function shown(t: Topic): ArticleRef[] {
|
||||
return expanded[t.topic_rank] ? t.articles : t.articles.slice(0, PREVIEW);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
||||
<header class="flex items-baseline justify-between">
|
||||
<h1 class="text-xl font-semibold text-default">뉴스 다이제스트</h1>
|
||||
{#if digest}
|
||||
<span class="text-xs text-dim">
|
||||
{digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제
|
||||
</span>
|
||||
{/if}
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<h1 class="text-xl font-semibold text-default">뉴스 다이제스트</h1>
|
||||
{#if digest}
|
||||
<span class="text-xs text-dim">
|
||||
{digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제 · 총 {digest.total_articles}건
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 날짜 picker (URL ?date= 동기화) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={older}
|
||||
disabled={!hasOlder}
|
||||
class="px-2 py-1 rounded-md text-sm border border-default text-dim hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="이전(과거) 날짜"
|
||||
>‹ 과거</button>
|
||||
|
||||
<select
|
||||
value={currentDate}
|
||||
onchange={onSelectDate}
|
||||
class="flex-1 min-w-0 rounded-md border border-default bg-surface px-2 py-1 text-sm text-default"
|
||||
aria-label="다이제스트 날짜 선택"
|
||||
>
|
||||
{#if dates.length === 0 && currentDate}
|
||||
<option value={currentDate}>{currentDate}</option>
|
||||
{/if}
|
||||
{#each dates as d (d.digest_date)}
|
||||
<option value={d.digest_date}>{d.digest_date} · {d.total_topics}주제 · {d.total_countries}국</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={newer}
|
||||
disabled={!hasNewer}
|
||||
class="px-2 py-1 rounded-md text-sm border border-default text-dim hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="다음(최신) 날짜"
|
||||
>최신 ›</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-32 w-full" />
|
||||
{:else if error}
|
||||
<EmptyState title="불러올 수 없음" description={error}>
|
||||
<Button variant="ghost" size="sm" onclick={load}>다시 시도</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => loadDigest(urlDate)}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
{:else if !digest || digest.countries.length === 0}
|
||||
<EmptyState
|
||||
title="새 digest 가 없습니다"
|
||||
description="오늘 04:00 KST cron 이 아직 실행되지 않았거나 결과가 없습니다."
|
||||
title="이 날짜의 digest 가 없습니다"
|
||||
description="다른 날짜를 선택하거나, 오늘 04:00 KST cron 실행을 기다려 주세요."
|
||||
/>
|
||||
{:else}
|
||||
<Tabs {tabs} bind:value={country}>
|
||||
{#snippet children(_activeId)}
|
||||
{#if topics.length === 0}
|
||||
<EmptyState
|
||||
title="이 국가의 topic 이 없습니다"
|
||||
description="다른 country 탭을 확인해 주세요."
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-3 mt-4">
|
||||
{#each topics as t (t.topic_rank)}
|
||||
<Card class="p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-default">
|
||||
{t.topic_rank}. {t.topic_label}
|
||||
</h3>
|
||||
{#if t.llm_fallback_used}
|
||||
<Badge>fallback</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-default">{t.summary}</p>
|
||||
<div class="mt-2 text-xs text-dim">
|
||||
{t.article_count} articles · importance {t.importance_score.toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
<!-- 국가 탭 (국기 SVG + 한국어, URL ?country= 동기화) -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each digest.countries as c (c.country)}
|
||||
{@const lbl = countryLabel(c.country)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => pickCountry(c.country)}
|
||||
aria-current={activeCountry === c.country ? 'true' : undefined}
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm border transition-colors
|
||||
{activeCountry === c.country
|
||||
? 'border-accent bg-accent/15 text-accent'
|
||||
: 'border-default text-dim hover:bg-surface hover:text-default'}"
|
||||
>
|
||||
{#if lbl.flag}
|
||||
<span class="flag inline-block w-[18px] h-[12px] rounded-[2px] overflow-hidden ring-1 ring-black/10 shrink-0">{@html lbl.flag}</span>
|
||||
{:else}
|
||||
<span class="inline-block rounded-[3px] bg-surface px-1 text-[10px] font-semibold text-dim">{c.country}</span>
|
||||
{/if}
|
||||
<span>{lbl.ko}</span>
|
||||
<span class="text-xs opacity-70">{c.topics.length}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if topics.length === 0}
|
||||
<EmptyState
|
||||
title="이 국가의 topic 이 없습니다"
|
||||
description="다른 country 탭을 확인해 주세요."
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each topics as t (t.topic_rank)}
|
||||
<Card class="p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-default">
|
||||
{t.topic_rank}. {t.topic_label}
|
||||
</h3>
|
||||
{#if t.llm_fallback_used}
|
||||
<Badge>fallback</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-default">{t.summary}</p>
|
||||
<div class="mt-1 text-xs text-dim">
|
||||
{t.article_count} articles · importance {t.importance_score.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{#if t.articles.length > 0}
|
||||
<ul class="mt-3 space-y-1 border-t border-default pt-2">
|
||||
{#each shown(t) as a (a.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`/documents/${a.id}`}
|
||||
class="block truncate text-sm text-dim hover:text-accent hover:underline"
|
||||
title={a.title ?? '(제목 없음)'}
|
||||
>
|
||||
{a.title ?? '(제목 없음)'}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if t.articles.length > PREVIEW && !expanded[t.topic_rank]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = { ...expanded, [t.topic_rank]: true })}
|
||||
class="mt-1 text-xs text-accent hover:underline"
|
||||
>
|
||||
외 {t.articles.length - PREVIEW}건 펼치기
|
||||
</button>
|
||||
{:else if t.articles.length > PREVIEW}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = { ...expanded, [t.topic_rank]: false })}
|
||||
class="mt-1 text-xs text-dim hover:underline"
|
||||
>
|
||||
접기
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user