Merge pull request 'feat/morning-briefing-frontend' (#11) from feat/morning-briefing-frontend into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-05-12 14:53:18 +09:00
2 changed files with 194 additions and 335 deletions
+1 -1
View File
@@ -98,7 +98,7 @@
<Button variant="ghost" size="sm" href="/ask" class={isActive('/ask') ? 'text-accent' : ''}>질문</Button>
<Button variant="ghost" size="sm" href="/memos" class={isActive('/memos') ? 'text-accent' : ''}>메모</Button>
<Button variant="ghost" size="sm" href="/study" class={isActive('/study') ? 'text-accent' : ''}>공부</Button>
<Button variant="ghost" size="sm" href="/news" class={isActive('/news') ? 'text-accent' : ''}>뉴스</Button>
<Button variant="ghost" size="sm" href="/news" class={isActive('/news') ? 'text-accent' : ''}>아침 브리핑</Button>
<Button variant="ghost" size="sm" href="/inbox" class={isActive('/inbox') ? 'text-accent' : ''}>Inbox</Button>
<div class="relative">
<IconButton
+193 -334
View File
@@ -1,357 +1,216 @@
<script>
<script lang="ts">
// 야간 수집 뉴스 브리핑 (Morning Briefing) — 매일 KST 05:10 cron 으로 만들어진
// topic×country 비교 분석 1페이지 카드. 기존 article list / source tree /
// 북마크 / 노트 / 필터 UI 는 폐기 (PR-MorningBriefing-2 swap).
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { Bookmark, BookmarkCheck } from 'lucide-svelte';
import { api, type ApiError } from '$lib/api';
import Card from '$lib/components/ui/Card.svelte';
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
});
type CountryPerspective = {
country: string;
summary: string;
article_ids: number[];
};
type KeyQuote = {
country: string;
source: string;
quote: string;
};
type BriefingTopic = {
topic_rank: number;
topic_label: string;
headline: string;
country_perspectives: CountryPerspective[];
divergences: string[];
convergences: string[];
key_quotes: KeyQuote[];
historical_context: string | null;
cluster_members: number[];
article_count: number;
country_count: number;
importance_score: number;
llm_fallback_used: boolean;
};
type Briefing = {
briefing_date: string;
window_start: string;
window_end: string;
total_articles: number;
total_countries: number;
total_topics: number;
llm_calls: number;
llm_failures: number;
status: 'success' | 'partial' | 'failed' | 'empty';
headline_oneliner: string | null;
topics: BriefingTopic[];
};
const COUNTRY_META: Record<string, { flag: string; label: string }> = {
KR: { flag: '🇰🇷', label: '한국' },
US: { flag: '🇺🇸', label: '미국' },
JP: { flag: '🇯🇵', label: '일본' },
CN: { flag: '🇨🇳', label: '중국' },
HK: { flag: '🇭🇰', label: '홍콩' },
TW: { flag: '🇹🇼', label: '대만' },
DE: { flag: '🇩🇪', label: '독일' },
FR: { flag: '🇫🇷', label: '프랑스' },
GB: { flag: '🇬🇧', label: '영국' },
UK: { flag: '🇬🇧', label: '영국' },
IN: { flag: '🇮🇳', label: '인도' },
RU: { flag: '🇷🇺', label: '러시아' },
IR: { flag: '🇮🇷', label: '이란' },
IL: { flag: '🇮🇱', label: '이스라엘' },
PH: { flag: '🇵🇭', label: '필리핀' },
AU: { flag: '🇦🇺', label: '호주' },
NL: { flag: '🇳🇱', label: '네덜란드' },
};
function countryLabel(code: string): string {
const meta = COUNTRY_META[code?.toUpperCase()];
return meta ? `${meta.flag} ${meta.label}` : code;
}
let articles = $state([]);
let total = $state(0);
let briefing = $state<Briefing | null>(null);
let loading = $state(true);
let selectedArticle = $state(null);
let filterSource = $state('');
let showUnreadOnly = $state(false);
let showPinnedOnly = $state(false);
let sourceTree = $state({});
let currentPage = $state(1);
let noteEditing = $state(false);
let noteText = $state('');
let contentEditing = $state(false);
let contentText = $state('');
let filterOpen = $state(false);
let expandedSources = $state({});
let previewEl = $state(null);
const PAPER_NAMES = {
'경향신문': '경향신문', '朝日新聞': '朝日新聞', 'NYT': 'NYT',
'Le Monde': 'Le Monde', 'Der Spiegel': 'Der Spiegel', '新华网': '新华网',
};
let errorMsg = $state<string | null>(null);
onMount(async () => {
try {
const srcData = await api('/news/sources');
const tree = {};
srcData.forEach(s => {
let paper = '';
for (const [key] of Object.entries(PAPER_NAMES)) {
if (s.name.startsWith(key)) { paper = key; break; }
}
if (!paper) paper = s.name;
const cat = s.category || '';
if (!tree[paper]) tree[paper] = [];
if (cat && !tree[paper].includes(cat)) tree[paper].push(cat);
});
sourceTree = tree;
} catch (e) {}
loadArticles();
});
async function loadArticles() {
loading = true;
try {
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('page_size', '30');
if (filterSource) params.set('source', filterSource);
if (showUnreadOnly) params.set('unread_only', 'true');
if (showPinnedOnly) params.set('pinned_only', 'true');
const data = await api(`/news/articles?${params}`);
articles = data.items;
total = data.total;
// 필터 변경 후 selectedArticle 재동기화
if (selectedArticle) {
const match = articles.find(a => a.id === selectedArticle.id);
selectedArticle = match || null;
}
} catch (err) {
addToast('error', '뉴스 로딩 실패');
} finally { loading = false; }
}
function selectArticle(article) {
selectedArticle = article;
noteEditing = false;
contentEditing = false;
if (!article.is_read) markRead(article);
// 미리보기 스크롤 초기화
requestAnimationFrame(() => {
previewEl?.scrollTo(0, 0);
});
}
function closeArticle() {
selectedArticle = null;
noteEditing = false;
contentEditing = false;
}
async function markRead(article) {
try {
await api(`/documents/${article.id}`, { method: 'PATCH', body: JSON.stringify({ is_read: true }) });
article.is_read = true;
articles = [...articles];
} catch (e) {}
}
async function markAllRead() {
try {
const result = await api('/news/mark-all-read', { method: 'POST' });
addToast('success', `${result.marked}건 읽음 처리`);
articles = articles.map(a => ({ ...a, is_read: true }));
} catch (e) { addToast('error', '실패'); }
}
async function togglePin(article) {
if (!article) return;
try {
const newPinned = !article.pinned;
await api(`/documents/${article.id}`, {
method: 'PATCH',
body: JSON.stringify({ pinned: newPinned })
});
articles = articles.map(a =>
a.id === article.id ? { ...a, pinned: newPinned } : a
);
if (selectedArticle?.id === article.id) {
selectedArticle = { ...selectedArticle, pinned: newPinned };
}
briefing = await api<Briefing>('/briefing/latest');
} catch (e) {
addToast('error', '책갈피 변경 실패');
const err = e as ApiError;
errorMsg = err?.status === 404
? '아직 생성된 브리핑이 없습니다. 매일 새벽 05:10 KST 에 자동 생성됩니다.'
: (err?.detail || '브리핑을 불러오지 못했습니다.');
} finally {
loading = false;
}
}
async function saveContent() {
try {
const newText = selectedArticle.extracted_text + '\n\n---\n\n' + contentText;
await api(`/documents/${selectedArticle.id}/content`, { method: 'PUT', body: JSON.stringify({ content: newText }) });
selectedArticle.extracted_text = newText;
contentEditing = false;
addToast('success', '본문 저장됨');
} catch (e) { addToast('error', '저장 실패'); }
}
async function saveNote() {
try {
await api(`/documents/${selectedArticle.id}`, { method: 'PATCH', body: JSON.stringify({ user_note: noteText }) });
selectedArticle.user_note = noteText;
noteEditing = false;
addToast('success', '메모 저장됨');
} catch (e) { addToast('error', '저장 실패'); }
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
return `${Math.floor(hours / 24)}일 전`;
}
function applyFilter(src) {
filterSource = src;
filterOpen = false;
}
let prevFilter = '';
$effect(() => {
const key = `${filterSource}|${showUnreadOnly}|${showPinnedOnly}`;
if (key !== prevFilter) { prevFilter = key; currentPage = 1; loadArticles(); }
});
let unreadCount = $derived(articles.filter(a => !a.is_read).length);
const fallbackPct = $derived(
briefing && briefing.llm_calls > 0
? Math.round((briefing.llm_failures / briefing.llm_calls) * 100)
: 0
);
</script>
<div class="flex h-full overflow-hidden">
<!-- 데스크톱 사이드바 필터 -->
<div class="hidden lg:block w-48 shrink-0 border-r border-[var(--border)] bg-[var(--sidebar-bg)] p-3 overflow-y-auto">
<h2 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-3">필터</h2>
<button onclick={() => applyFilter('')} class="w-full text-left px-2 py-1.5 rounded text-sm mb-1 {filterSource === '' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">📰 전체</button>
{#each Object.entries(sourceTree) as [paper, categories]}
<div class="mb-0.5">
<button onclick={() => { applyFilter(paper); expandedSources[paper] = !expandedSources[paper]; }}
class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center justify-between {filterSource === paper ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">
<span>{paper}</span>
<span class="text-[10px]">{expandedSources[paper] ? '▼' : '▶'}</span>
</button>
{#if expandedSources[paper] && categories.length > 0}
{#each categories as cat}
<button onclick={() => applyFilter(`${paper}/${cat}`)}
class="w-full text-left pl-6 pr-2 py-1 rounded text-xs {filterSource === `${paper}/${cat}` ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">{cat}</button>
{/each}
{/if}
</div>
{/each}
<hr class="my-3 border-[var(--border)]">
<label class="flex items-center gap-2 px-2 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
읽지 않음만
</label>
<label class="flex items-center gap-2 px-2 mt-2 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showPinnedOnly} class="rounded">
책갈피만
</label>
</div>
<!-- 메인 -->
<div class="flex-1 flex flex-col min-h-0">
<!-- 상단 바 -->
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border)] shrink-0">
<div class="flex items-center gap-2">
<!-- 모바일 필터 드롭다운 -->
<button onclick={() => filterOpen = !filterOpen}
class="lg:hidden flex items-center gap-1 px-2 py-1 text-xs rounded border border-[var(--border)] text-[var(--text-dim)]">
📰 {filterSource || '전체'} <span class="text-[10px]">{filterOpen ? '▲' : '▼'}</span>
</button>
<!-- 모바일 안읽음 -->
<label class="lg:hidden flex items-center gap-1 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded"> 안읽음
</label>
<!-- 모바일 책갈피 -->
<label class="lg:hidden flex items-center gap-1 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showPinnedOnly} class="rounded"> 책갈피
</label>
<span class="text-xs text-[var(--text-dim)]">{total}건</span>
{#if unreadCount > 0}
<span class="text-[10px] text-[var(--accent)]">● {unreadCount} 안읽음</span>
{/if}
</div>
<button onclick={markAllRead} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)] px-2 py-1 rounded border border-[var(--border)]">전체 읽음</button>
</div>
<!-- 모바일 필터 드롭다운 내용 -->
{#if filterOpen}
<div class="lg:hidden border-b border-[var(--border)] bg-[var(--sidebar-bg)] px-3 py-2 max-h-60 overflow-y-auto">
<button onclick={() => applyFilter('')} class="block w-full text-left px-2 py-2 rounded text-sm {filterSource === '' ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">📰 전체</button>
{#each Object.entries(sourceTree) as [paper, categories]}
<button onclick={() => applyFilter(paper)} class="block w-full text-left px-2 py-2 rounded text-sm {filterSource === paper ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">{paper}</button>
{#each categories as cat}
<button onclick={() => applyFilter(`${paper}/${cat}`)} class="block w-full text-left pl-6 pr-2 py-1.5 rounded text-xs {filterSource === `${paper}/${cat}` ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">{cat}</button>
{/each}
{/each}
</div>
{/if}
<!-- 기사 리스트 -->
<div class="news-list overflow-y-auto {selectedArticle
? 'shrink-0 h-[35%] min-h-[180px] max-h-[260px] border-b border-[var(--border)] lg:h-[40%] lg:min-h-0 lg:max-h-none'
: 'flex-1'}">
{#if loading}
<div class="p-4 space-y-2">
{#each Array(5) as _}
<div class="h-16 bg-[var(--surface)] rounded animate-pulse"></div>
{/each}
</div>
{:else if articles.length === 0}
<div class="text-center py-16 text-[var(--text-dim)]"><p class="text-sm">뉴스가 없습니다</p></div>
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
<header class="space-y-1">
<h1 class="text-xl font-semibold">야간 뉴스 브리핑</h1>
<p class="text-sm text-dim">
{#if briefing}
{briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽
{:else}
{#each articles as article}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => selectArticle(article)} role="button" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectArticle(article); } }}
class="w-full text-left px-3 py-2 lg:px-4 lg:py-3 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] active:bg-[var(--surface)] transition-colors cursor-pointer
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}">
<div class="flex items-start gap-2">
<span class="mt-1.5 text-[10px] shrink-0 {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">{article.is_read ? '○' : '●'}</span>
<div class="min-w-0 flex-1">
<p class="text-[13px] lg:text-sm leading-snug {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
<p class="text-xs text-[var(--text-dim)] mt-1 line-clamp-1 lg:line-clamp-2">{(article.ai_summary?.replace(/[*#_`~]/g, '') || article.extracted_text?.split('\n').filter(l => l.trim() && l !== article.title)[0] || '').slice(0, 120)}</p>
<div class="flex items-center gap-2 mt-1 text-[10px] text-[var(--text-dim)]">
<span>{article.ai_sub_group || ''}</span>
{#if article.ai_tags?.length}<span>{article.ai_tags[0]?.split('/').pop()}</span>{/if}
<span>{timeAgo(article.created_at)}</span>
</div>
</div>
<button
onclick={(e) => { e.stopPropagation(); togglePin(article); }}
class="shrink-0 p-2 -m-1 rounded text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors"
aria-label={article.pinned ? '책갈피 해제' : '책갈피 추가'}
>
{#if article.pinned}
<BookmarkCheck size={14} class="text-[var(--accent)]" />
{:else}
<Bookmark size={14} />
{/if}
</button>
</div>
</div>
{/each}
매일 KST 자정~05:00 누적 뉴스를 주제별로 다국 비교 분석합니다.
{/if}
</div>
</p>
</header>
<!-- 페이지네이션 -->
{#if total > 30 && !selectedArticle}
<div class="flex justify-center gap-1 py-2 border-t border-[var(--border)] shrink-0">
{#each Array(Math.min(Math.ceil(total / 30), 10)) as _, i}
<button onclick={() => { currentPage = i + 1; loadArticles(); }}
class="px-2.5 py-1 rounded text-xs {currentPage === i + 1 ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-dim)]'}">{i + 1}</button>
{/each}
</div>
{/if}
<!-- 미리보기 패널 (모바일+데스크톱 통합) -->
{#if selectedArticle}
<div bind:this={previewEl} class="news-preview flex-1 overflow-y-auto bg-[var(--surface)] min-h-0">
<div class="flex items-center justify-between px-3 py-1.5 lg:px-5 lg:py-2 border-b border-[var(--border)] sticky top-0 bg-[var(--surface)] z-10">
<div class="flex items-center gap-2 text-xs text-[var(--text-dim)]">
<span>{selectedArticle.ai_sub_group}</span><span>·</span><span>{timeAgo(selectedArticle.created_at)}</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => togglePin(selectedArticle)}
class="text-xs flex items-center gap-1 {selectedArticle.pinned ? 'text-[var(--accent)]' : 'text-[var(--text-dim)] hover:text-[var(--accent)]'}"
aria-label={selectedArticle.pinned ? '책갈피 해제' : '책갈피 추가'}
>
{#if selectedArticle.pinned}
<BookmarkCheck size={14} /> 책갈피
{:else}
<Bookmark size={14} /> 책갈피
{/if}
</button>
{#if selectedArticle.edit_url}<a href={selectedArticle.edit_url} target="_blank" rel="noopener noreferrer" class="text-xs text-[var(--accent)]">원문 →</a>{/if}
<button onclick={closeArticle} class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">닫기</button>
</div>
{#if loading}
<Card>
<p class="text-sm text-dim">불러오는 중…</p>
</Card>
{:else if errorMsg}
<Card>
<p class="text-sm">{errorMsg}</p>
</Card>
{:else if briefing}
{#if briefing.status === 'empty'}
<Card>
<p class="text-sm">
오늘 새벽({briefing.briefing_date}) 다국 비교 가능한 토픽이 없습니다.
</p>
<p class="mt-2 text-xs text-dim">
(수집 뉴스 0건 또는 2개국 이상 다룬 주제 없음)
</p>
</Card>
{:else}
{#if briefing.status === 'failed'}
<div class="border border-error/40 bg-error/10 text-sm rounded-md px-4 py-3">
⚠ LLM 분석 실패율이 높습니다 ({briefing.llm_failures}/{briefing.llm_calls}, {fallbackPct}%). 일부 토픽이 원문 묶음으로 표시됩니다.
</div>
<div class="p-3 lg:p-5">
<h2 class="text-base lg:text-lg font-bold mb-2 lg:mb-3">{selectedArticle.title}</h2>
{#if selectedArticle.ai_summary}
<div class="mb-4 p-3 bg-[var(--accent)]/5 border border-[var(--accent)]/20 rounded-lg">
<h4 class="text-[10px] font-semibold text-[var(--accent)] uppercase mb-1">AI 요약</h4>
<div class="text-sm markdown-body">{@html renderMd(selectedArticle.ai_summary)}</div>
{:else if briefing.status === 'partial'}
<div class="border border-warning/40 bg-warning/10 text-sm rounded-md px-4 py-3">
일부 토픽 LLM 실패 ({briefing.llm_failures}/{briefing.llm_calls}). 다른 토픽은 정상 분석되었습니다.
</div>
{/if}
{#each briefing.topics as topic (topic.topic_rank)}
<Card>
<div class="space-y-3">
<div class="flex items-start gap-2">
<span class="text-xs text-faint shrink-0 pt-1">#{topic.topic_rank}</span>
<div class="flex-1 min-w-0">
<h2 class="text-base font-semibold leading-snug">
{topic.topic_label}
{#if topic.llm_fallback_used}
<span class="ml-1 text-xs text-dim">(원문 묶음)</span>
{/if}
</h2>
<p class="text-sm text-dim mt-1">{topic.headline}</p>
<p class="text-xs text-faint mt-1">
{topic.country_count}개국 · {topic.article_count}
</p>
</div>
</div>
{/if}
<div class="markdown-body mb-4">{@html renderMd(selectedArticle.extracted_text || '')}</div>
<div class="border-t border-[var(--border)] pt-4 mt-4">
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">본문 입력</h4>
{#if contentEditing}
<textarea bind:value={contentText} class="w-full h-32 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-y outline-none" placeholder="기사 전문을 붙여넣으세요..."></textarea>
<div class="flex gap-2 mt-2"><button onclick={saveContent} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => contentEditing = false} class="px-3 py-1 text-xs text-[var(--text-dim)]">취소</button></div>
{:else}<button onclick={() => { contentText = ''; contentEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 본문 입력</button>{/if}
</div>
<div class="border-t border-[var(--border)] pt-4 mt-4">
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">메모</h4>
{#if noteEditing}
<textarea bind:value={noteText} class="w-full h-20 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-y outline-none" placeholder="메모..."></textarea>
<div class="flex gap-2 mt-2"><button onclick={saveNote} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => noteEditing = false} class="px-3 py-1 text-xs text-[var(--text-dim)]">취소</button></div>
{:else}
<button onclick={() => { noteText = selectedArticle.user_note || ''; noteEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 메모 추가</button>
{#if selectedArticle.user_note}<div class="mt-2 p-3 bg-[var(--bg)] rounded-lg text-sm">{selectedArticle.user_note}</div>{/if}
{#if topic.country_perspectives.length > 0}
<div class="space-y-1.5">
{#each topic.country_perspectives as cp}
<div class="text-sm leading-relaxed">
<span class="font-medium">{countryLabel(cp.country)}</span>
<span class="text-dim mx-1">·</span>
<span>{cp.summary}</span>
{#if cp.article_ids.length > 0}
<span class="ml-1 text-xs text-faint">
{#each cp.article_ids as id, i}
{#if i > 0}<span class="mx-0.5">·</span>{/if}<a
href={`/documents/${id}`}
class="hover:text-accent"
>#{id}</a>
{/each}
</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if topic.divergences.length > 0}
<div class="text-xs">
<span class="text-dim">차이 </span>
<span class="text-text">{topic.divergences.join(' · ')}</span>
</div>
{/if}
{#if topic.convergences.length > 0}
<div class="text-xs">
<span class="text-dim">공통 </span>
<span class="text-text">{topic.convergences.join(' · ')}</span>
</div>
{/if}
{#if topic.key_quotes.length > 0}
<ul class="text-xs space-y-1 border-l-2 border-default pl-3">
{#each topic.key_quotes as q}
<li>
<span class="text-dim">{countryLabel(q.country)} · {q.source}</span>
<span class="text-text">"{q.quote}"</span>
</li>
{/each}
</ul>
{/if}
{#if topic.historical_context}
<p class="text-xs text-faint italic">
↩ 지난 흐름 · {topic.historical_context}
</p>
{/if}
</div>
</div>
</div>
</Card>
{/each}
{/if}
</div>
{/if}
</div>