feat(ui): DS 웹 UI 세이지 재설계 5페이지 일괄 배포 (대시보드·문서·뉴스·digest·메모)
goal 1~4 + digest. 적대 리뷰(15-agent) 확정 결함 반영본. frontend-only.
원본 커밋 = gitea feat/ui-sage-all a1a46f2. eid 마이그레이션 301~305 워킹트리
격리(미티게이션) 미접촉.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
dashboardSummary,
|
||||
refresh,
|
||||
type DashboardSummary,
|
||||
type PipelineStatus,
|
||||
type QueueLag,
|
||||
@@ -67,6 +68,7 @@
|
||||
await api('/memos/', { method: 'POST', body: JSON.stringify({ content }) });
|
||||
captureText = '';
|
||||
addToast('success', '메모 저장됨');
|
||||
void refresh(); // 메모 수 등 요약 즉시 갱신(60s 폴 기다리지 않음)
|
||||
} catch {
|
||||
addToast('error', '메모 저장 실패');
|
||||
} finally {
|
||||
@@ -103,7 +105,7 @@
|
||||
article_count: topics[0].article_count,
|
||||
importance_score: topics[0].importance_score,
|
||||
country: topics[0].country,
|
||||
date: d.date,
|
||||
date: d.digest_date,
|
||||
};
|
||||
}
|
||||
} catch { /* 디제스트 없으면 블록 자동 생략 */ }
|
||||
@@ -182,6 +184,7 @@
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return ''; // 빈 문자열/유효하지 않은 created_at → 'Invalid Date' 회피
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60000) return '방금';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
|
||||
@@ -262,7 +265,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 스탯 띠 -->
|
||||
<div class="flex flex-wrap gap-y-3 border-t border-default mt-4 pt-4">
|
||||
<div class="flex flex-nowrap overflow-x-auto border-t border-default mt-4 pt-4">
|
||||
{@render stat((summary.documents_count ?? 0).toLocaleString(), '문서', 'text-accent')}
|
||||
{@render stat((summary.news_count ?? 0).toLocaleString(), '뉴스')}
|
||||
{#if domainTotal > 0}
|
||||
@@ -480,6 +483,6 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
details[open] .details-chevron { transform: rotate(90deg); }
|
||||
details[open] :global(.details-chevron) { transform: rotate(90deg); }
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
|
||||
@@ -410,20 +410,20 @@
|
||||
</div><!-- /.digest-page -->
|
||||
|
||||
<style>
|
||||
/* ── 웜 팔레트 로컬 재정의 ──
|
||||
/* ── 세이지 팔레트 로컬 재정의 ──
|
||||
앱 :root 다크 토큰(--surface:#1a1d27, --accent:파랑 등)이 하위 var() 로 새지 않도록
|
||||
이 subtree 에서 웜값으로 덮어쓴다. 하위 모든 var(--surface/--card/--line/--brand …)는
|
||||
이 subtree 에서 세이지값으로 덮어쓴다. 하위 모든 var(--surface/--card/--line/--brand …)는
|
||||
여기서 해석된다. 검정(#000/#1f2024) 미사용. */
|
||||
.digest-page {
|
||||
--brand: #d97757;
|
||||
--brand-d: #c2603f;
|
||||
--surface: #f0eee6;
|
||||
--brand: #4f8a6b;
|
||||
--brand-d: #3d7256;
|
||||
--surface: #ecf0e8;
|
||||
--card: #fff;
|
||||
--ink: #2e2420;
|
||||
--muted: #6b6f76;
|
||||
--line: #e3e0d6;
|
||||
--ink: #23291f;
|
||||
--muted: #697061;
|
||||
--line: #dde3d6;
|
||||
font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', system-ui, sans-serif;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
}
|
||||
|
||||
/* ── App shell ── */
|
||||
@@ -437,14 +437,14 @@
|
||||
|
||||
/* ── Masthead ── */
|
||||
header.bar {
|
||||
background: #faf7f1;
|
||||
background: #f4f7f1;
|
||||
border-bottom: 3px solid var(--brand);
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 56px;
|
||||
box-shadow: 0 1px 0 #e3e0d6;
|
||||
box-shadow: 0 1px 0 #dde3d6;
|
||||
}
|
||||
header.bar .mark {
|
||||
display: flex;
|
||||
@@ -467,7 +467,7 @@
|
||||
header.bar h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
@@ -493,11 +493,11 @@
|
||||
header.bar .stat-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
}
|
||||
header.bar .stat-lbl {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -508,20 +508,20 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.date-btn {
|
||||
background: #f0eee6;
|
||||
border: 1px solid #d8d3c8;
|
||||
background: #ecf0e8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.date-btn:hover:not(:disabled) {
|
||||
background: #e7e2d6;
|
||||
background: #e3ebdf;
|
||||
color: var(--brand-d);
|
||||
}
|
||||
.date-btn:disabled {
|
||||
@@ -529,13 +529,13 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.date-select {
|
||||
background: #f0eee6;
|
||||
border: 1px solid #d8d3c8;
|
||||
background: #ecf0e8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
letter-spacing: 0.02em;
|
||||
max-width: 220px;
|
||||
cursor: pointer;
|
||||
@@ -561,7 +561,7 @@
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -571,8 +571,8 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
.country-nav .nav-item:hover {
|
||||
color: #3a322a;
|
||||
border-bottom-color: #d8d3c8;
|
||||
color: #333a2d;
|
||||
border-bottom-color: #cfd7c6;
|
||||
}
|
||||
.country-nav .nav-item.active {
|
||||
color: var(--brand);
|
||||
@@ -586,16 +586,16 @@
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--surface);
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.country-nav .nav-item.active .cc-chip {
|
||||
background: rgba(217, 119, 87, 0.15);
|
||||
background: rgba(79, 138, 107, 0.15);
|
||||
color: var(--brand);
|
||||
}
|
||||
.country-nav .topic-count {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
@@ -626,7 +626,7 @@
|
||||
.edition-line .edition-date {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.edition-line .edition-sep {
|
||||
@@ -636,13 +636,13 @@
|
||||
}
|
||||
.edition-line .edition-sub {
|
||||
font-size: 11px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Lead story block ── */
|
||||
.lead-block {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-top: 4px solid var(--brand);
|
||||
border-radius: 4px;
|
||||
padding: 28px 32px 24px;
|
||||
@@ -657,7 +657,7 @@
|
||||
right: 0;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, rgba(217, 119, 87, 0.05), transparent);
|
||||
background: linear-gradient(to left, rgba(79, 138, 107, 0.05), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lead-meta {
|
||||
@@ -683,9 +683,9 @@
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
background: var(--surface);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -700,27 +700,27 @@
|
||||
}
|
||||
.lead-meta .cnt-badge {
|
||||
font-size: 11px;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.lead-meta .cnt-badge strong {
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lead-headline {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.lead-summary {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0 0 20px;
|
||||
max-width: 680px;
|
||||
}
|
||||
@@ -747,7 +747,7 @@
|
||||
.lead-articles a {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
text-decoration: none;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -766,7 +766,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lead-imp-bar .bar-track {
|
||||
@@ -800,7 +800,7 @@
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-head .sh-line {
|
||||
@@ -820,7 +820,7 @@
|
||||
/* ── Story card ── */
|
||||
.story-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 18px 20px 16px;
|
||||
display: flex;
|
||||
@@ -829,7 +829,7 @@
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.story-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(90, 70, 55, 0.09);
|
||||
box-shadow: 0 2px 12px rgba(74, 81, 66, 0.09);
|
||||
}
|
||||
.story-card.featured {
|
||||
border-top: 3px solid var(--brand-d);
|
||||
@@ -846,9 +846,9 @@
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
background: var(--surface);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -863,21 +863,21 @@
|
||||
}
|
||||
.card-meta .cnt-tag {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.card-summary {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -891,7 +891,7 @@
|
||||
.card-articles a {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #3a322a;
|
||||
color: #333a2d;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
@@ -904,7 +904,7 @@
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: #b8a898;
|
||||
background: #9aa090;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -923,7 +923,7 @@
|
||||
.card-imp .imp-val {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
}
|
||||
|
||||
/* ── Sidebar stack ── */
|
||||
@@ -934,7 +934,7 @@
|
||||
}
|
||||
.sidebar-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
@@ -958,24 +958,24 @@
|
||||
.sidebar-card .cc-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.sidebar-card .sc-cnt {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.sidebar-card .s-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-card .s-summary {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-card .s-link {
|
||||
@@ -995,7 +995,7 @@
|
||||
}
|
||||
.compact-card {
|
||||
background: var(--card);
|
||||
border: 1px solid #d8d3c8;
|
||||
border: 1px solid #cfd7c6;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
@@ -1019,24 +1019,24 @@
|
||||
.compact-card .c-ko {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a6e64;
|
||||
color: #697061;
|
||||
}
|
||||
.compact-card .c-cnt {
|
||||
font-size: 10px;
|
||||
color: #9a8e84;
|
||||
color: #9aa090;
|
||||
margin-left: auto;
|
||||
}
|
||||
.compact-card .c-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #2e2420;
|
||||
color: #23291f;
|
||||
margin: 0;
|
||||
}
|
||||
.compact-card .c-summary {
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #5a4f46;
|
||||
color: #4a5142;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1056,11 +1056,11 @@
|
||||
.compact-card .c-imp-fill {
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: rgba(217, 119, 87, 0.6);
|
||||
background: rgba(79, 138, 107, 0.6);
|
||||
}
|
||||
|
||||
/* ── Importance swatches ── */
|
||||
.imp-high { background: rgba(217, 119, 87, 0.85); }
|
||||
.imp-high { background: rgba(79, 138, 107, 0.85); }
|
||||
|
||||
/* 극단적 긴 무공백 토큰(연속 CJK·URL) 가로 오버플로 방어 */
|
||||
.lead-headline, .lead-summary, .card-title, .card-summary,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,10 +292,10 @@
|
||||
};
|
||||
const KIND_BADGE_CLASS = {
|
||||
note: 'bg-surface text-dim',
|
||||
task: 'bg-indigo-100 text-indigo-700',
|
||||
calendar_event: 'bg-blue-100 text-blue-700',
|
||||
activity_log: 'bg-emerald-100 text-emerald-700',
|
||||
reference: 'bg-amber-100 text-amber-700',
|
||||
task: 'bg-accent/15 text-accent-hover',
|
||||
calendar_event: 'bg-domain-engineering/15 text-domain-engineering',
|
||||
activity_log: 'bg-success/15 text-success',
|
||||
reference: 'bg-domain-reference/15 text-domain-reference',
|
||||
};
|
||||
|
||||
async function handleCheckboxClick(e, memo) {
|
||||
@@ -400,9 +400,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 빠른 입력 ═══ -->
|
||||
<!-- ═══ 빠른 입력 (상단 고정) ═══ -->
|
||||
{#if !showArchived}
|
||||
<Card class="mb-5">
|
||||
<Card class="mb-5 sticky top-0 z-10 shadow-sm">
|
||||
<!-- 선택적 제목 -->
|
||||
{#if showTitle}
|
||||
<input
|
||||
@@ -526,7 +526,7 @@
|
||||
{#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted}
|
||||
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
|
||||
{#if memo.source_channel === 'voice'}
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-rose-100 text-rose-700" title="음성 메모">
|
||||
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-domain-philosophy/15 text-domain-philosophy" title="음성 메모">
|
||||
<Mic size={10} /> 음성
|
||||
</span>
|
||||
{/if}
|
||||
@@ -536,7 +536,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if memo._last_promoted}
|
||||
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
|
||||
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-success/15 text-success hover:bg-success/25">
|
||||
<ArrowRight size={10} /> events #{memo._last_promoted.event_id}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -586,13 +586,13 @@
|
||||
<!-- PR-2B: AI triage 결과 → 1-click promote 버튼 (분류 결과 있고 dismissed 아닌 메모) -->
|
||||
{#if editingId !== memo.id && memo.ai_event_kind && memo.ai_event_kind !== 'note' && !memo._last_promoted && !showArchived}
|
||||
<div class="flex flex-wrap gap-1 mt-2 pt-2 border-t border-default/30">
|
||||
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-indigo-500 text-white hover:bg-indigo-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-accent text-white hover:bg-accent-hover' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<FileText size={11} /> 할 일로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-domain-engineering text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Calendar size={11} /> 일정으로
|
||||
</button>
|
||||
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-success text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
|
||||
<Activity size={11} /> 활동으로
|
||||
</button>
|
||||
<button onclick={() => dismissEventSuggestion(memo.id)} class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover hover:text-text transition-colors">
|
||||
|
||||
@@ -1,101 +1,59 @@
|
||||
<script lang="ts">
|
||||
// 야간 수집 뉴스 브리핑 (Morning Briefing) — 매일 KST 05:10 cron 으로 만들어진
|
||||
// topic×country 비교 분석 1페이지 카드. 기존 article list / source tree /
|
||||
// 북마크 / 노트 / 필터 UI 는 폐기 (PR-MorningBriefing-2 swap).
|
||||
// 모닝브리핑 /news — 확정 시안 '편집 신문 1면'. 야간(KST 0~5h) 수집 뉴스를
|
||||
// topic×country 비교 분석. 전 기능 보존(국가 관점·기사ID·차이/공통·인용·지난흐름·읽음/별표·날짜).
|
||||
// 이모지 국기 → 국가 색칩(no-emoji 규칙). 데이터·API 는 기존 /briefing 그대로.
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type ApiError } from '$lib/api';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
|
||||
type CountryPerspective = {
|
||||
country: string;
|
||||
summary: string;
|
||||
article_ids: number[];
|
||||
};
|
||||
|
||||
type KeyQuote = {
|
||||
country: string;
|
||||
source: string;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
type CountryPerspective = { country: string; summary: string; article_ids: number[] };
|
||||
type KeyQuote = { country: string; source: string; quote: string };
|
||||
type BriefingTopic = {
|
||||
id: number;
|
||||
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;
|
||||
is_read: boolean;
|
||||
read_at: string | null;
|
||||
highlighted: boolean;
|
||||
highlighted_at: string | null;
|
||||
id: number; 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;
|
||||
is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null;
|
||||
};
|
||||
|
||||
type BriefingDateSummary = {
|
||||
briefing_date: string;
|
||||
total_topics: number;
|
||||
total_articles: number;
|
||||
status: string;
|
||||
read_count: number;
|
||||
highlighted_count: number;
|
||||
briefing_date: string; total_topics: number; total_articles: number;
|
||||
status: string; read_count: number; highlighted_count: number;
|
||||
};
|
||||
|
||||
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;
|
||||
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[];
|
||||
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: '네덜란드' },
|
||||
// 국가 라벨(한국어, 이모지 없음) + 색칩 토큰
|
||||
const COUNTRY_LABEL: Record<string, string> = {
|
||||
KR: '한국', US: '미국', JP: '일본', CN: '중국', HK: '홍콩', TW: '대만',
|
||||
DE: '독일', FR: '프랑스', GB: '영국', UK: '영국', IN: '인도', RU: '러시아',
|
||||
IR: '이란', IL: '이스라엘', PH: '필리핀', AU: '호주', NL: '네덜란드',
|
||||
};
|
||||
const COUNTRY_CHIP: Record<string, string> = {
|
||||
KR: 'bg-warning', US: 'bg-domain-engineering', JP: 'bg-domain-reference',
|
||||
DE: 'bg-accent-hover', HK: 'bg-domain-philosophy', CN: 'bg-error',
|
||||
TW: 'bg-domain-general', GB: 'bg-domain-engineering', UK: 'bg-domain-engineering',
|
||||
FR: 'bg-domain-philosophy', IN: 'bg-domain-reference', RU: 'bg-error',
|
||||
IL: 'bg-accent', IR: 'bg-warning',
|
||||
};
|
||||
function countryLabel(code: string): string {
|
||||
const meta = COUNTRY_META[code?.toUpperCase()];
|
||||
return meta ? `${meta.flag} ${meta.label}` : code;
|
||||
return COUNTRY_LABEL[code?.toUpperCase?.()] ?? code;
|
||||
}
|
||||
function countryChip(code: string): string {
|
||||
return COUNTRY_CHIP[code?.toUpperCase?.()] ?? 'bg-dim';
|
||||
}
|
||||
|
||||
let briefing = $state<Briefing | null>(null);
|
||||
let loading = $state(true);
|
||||
let errorMsg = $state<string | null>(null);
|
||||
// 2026-05-13 추가 — 날짜 선택 + 카드 액션
|
||||
let availableDates = $state<BriefingDateSummary[]>([]);
|
||||
let selectedDate = $state<string>(''); // YYYY-MM-DD ('' = 최신)
|
||||
let selectedDate = $state<string>('');
|
||||
|
||||
async function loadBriefing(dateStr: string) {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
loading = true; errorMsg = null;
|
||||
try {
|
||||
const path = dateStr ? `/briefing?date=${dateStr}` : '/briefing/latest';
|
||||
briefing = await api<Briefing>(path);
|
||||
@@ -109,216 +67,238 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDates() {
|
||||
try {
|
||||
availableDates = await api<BriefingDateSummary[]>('/briefing/dates');
|
||||
} catch {
|
||||
availableDates = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onDateChange() {
|
||||
loadBriefing(selectedDate);
|
||||
try { availableDates = await api<BriefingDateSummary[]>('/briefing/dates'); }
|
||||
catch { availableDates = []; }
|
||||
}
|
||||
function onDateChange() { loadBriefing(selectedDate); }
|
||||
|
||||
async function toggleRead(topic: BriefingTopic) {
|
||||
if (!briefing) return;
|
||||
const next = !topic.is_read;
|
||||
try {
|
||||
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/read`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||
);
|
||||
topic.is_read = r.is_read;
|
||||
topic.read_at = r.read_at;
|
||||
} catch (e) {
|
||||
console.error('toggleRead failed', e);
|
||||
}
|
||||
const r = await api<{ is_read: boolean; read_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/read`, { method: 'PATCH', body: JSON.stringify({ value: next }) });
|
||||
topic.is_read = r.is_read; topic.read_at = r.read_at;
|
||||
} catch (e) { console.error('toggleRead failed', e); }
|
||||
}
|
||||
|
||||
async function toggleHighlight(topic: BriefingTopic) {
|
||||
if (!briefing) return;
|
||||
const next = !topic.highlighted;
|
||||
try {
|
||||
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/highlight`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||
);
|
||||
topic.highlighted = r.highlighted;
|
||||
topic.highlighted_at = r.highlighted_at;
|
||||
} catch (e) {
|
||||
console.error('toggleHighlight failed', e);
|
||||
}
|
||||
const r = await api<{ highlighted: boolean; highlighted_at: string | null }>(
|
||||
`/briefing/topics/${topic.id}/highlight`, { method: 'PATCH', body: JSON.stringify({ value: next }) });
|
||||
topic.highlighted = r.highlighted; topic.highlighted_at = r.highlighted_at;
|
||||
} catch (e) { console.error('toggleHighlight failed', e); }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadDates(), loadBriefing('')]);
|
||||
});
|
||||
onMount(async () => { await Promise.all([loadDates(), loadBriefing('')]); });
|
||||
|
||||
const fallbackPct = $derived(
|
||||
briefing && briefing.llm_calls > 0
|
||||
? Math.round((briefing.llm_failures / briefing.llm_calls) * 100)
|
||||
: 0
|
||||
briefing && briefing.llm_calls > 0 ? Math.round((briefing.llm_failures / briefing.llm_calls) * 100) : 0
|
||||
);
|
||||
const highlightedCount = $derived(briefing ? briefing.topics.filter((t) => t.highlighted).length : 0);
|
||||
const leadTopic = $derived(briefing && briefing.topics.length > 0 ? briefing.topics[0] : null);
|
||||
const restTopics = $derived(briefing ? briefing.topics.slice(1) : []);
|
||||
function folio(rank: number) { return String(rank).padStart(2, '0'); }
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
||||
<header class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold">야간 뉴스 브리핑</h1>
|
||||
{#if availableDates.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="briefing-date" class="text-xs text-dim">날짜</label>
|
||||
<select
|
||||
id="briefing-date"
|
||||
bind:value={selectedDate}
|
||||
onchange={onDateChange}
|
||||
class="text-sm border border-default rounded-md px-2 py-1 bg-surface"
|
||||
>
|
||||
<option value="">최신</option>
|
||||
{#each availableDates as d}
|
||||
<option value={d.briefing_date}>
|
||||
{d.briefing_date} · {d.total_topics}토픽
|
||||
{#if d.highlighted_count > 0}⭐{d.highlighted_count}{/if}
|
||||
</option>
|
||||
<div class="nws bg-bg min-h-full p-4 lg:p-6">
|
||||
<div class="max-w-[1240px] mx-auto">
|
||||
|
||||
<!-- ═══ 마스트헤드 ═══ -->
|
||||
<header class="bg-surface border border-default rounded-lg relative overflow-hidden px-5 lg:px-7 pt-5 pb-4">
|
||||
<span class="absolute left-0 top-0 bottom-0 w-[5px] bg-accent"></span>
|
||||
<div class="flex justify-between items-end flex-wrap gap-3 border-b-2 border-text pb-2.5 mb-3">
|
||||
<div class="nws-serif font-extrabold tracking-tight text-text text-3xl lg:text-4xl leading-none">모닝브리핑</div>
|
||||
<div class="flex items-center gap-2.5 flex-wrap text-xs text-dim font-mono">
|
||||
{#if availableDates.length > 0}
|
||||
<select
|
||||
bind:value={selectedDate}
|
||||
onchange={onDateChange}
|
||||
class="bg-bg border border-default rounded-md px-2 py-1 text-xs text-text"
|
||||
aria-label="브리핑 날짜"
|
||||
>
|
||||
<option value="">최신</option>
|
||||
{#each availableDates as d}
|
||||
<option value={d.briefing_date}>{d.briefing_date} · {d.total_topics}토픽{#if d.highlighted_count > 0} · ★{d.highlighted_count}{/if}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if briefing}
|
||||
<span class="font-bold text-text">{briefing.briefing_date}</span>
|
||||
{/if}
|
||||
{#if briefing}
|
||||
<span>{briefing.total_topics}토픽{#if highlightedCount > 0} · 별표 <span class="text-warning font-bold">{highlightedCount}</span>{/if}</span>
|
||||
<span>새벽 수집</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if briefing?.headline_oneliner}
|
||||
<div class="nws-serif text-text font-semibold text-lg lg:text-[22px] leading-snug tracking-tight mb-3.5">
|
||||
<span class="block font-mono text-xs font-bold text-accent-hover uppercase tracking-wider mb-1">오늘의 한 줄</span>
|
||||
{briefing.headline_oneliner}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if briefing}
|
||||
<div class="flex flex-wrap border-t border-default pt-3">
|
||||
<div class="flex flex-col gap-0.5 pr-6 border-r border-default">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_articles}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">총 기사</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-6 border-r border-default">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_countries}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">개국</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 px-6">
|
||||
<span class="nws-serif text-2xl font-extrabold text-text leading-none">{briefing.total_topics}</span>
|
||||
<span class="text-[11px] text-dim uppercase tracking-wide">토픽</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if briefing && (briefing.status === 'partial' || briefing.status === 'failed')}
|
||||
<div class="flex items-center gap-2.5 mt-3.5 px-3.5 py-2 rounded-md text-[13px]
|
||||
{briefing.status === 'failed' ? 'bg-error/10 border border-error/30 text-error' : 'bg-warning/10 border border-warning/30 text-warning'}">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {briefing.status === 'failed' ? 'bg-error' : 'bg-warning'}"></span>
|
||||
{#if briefing.status === 'failed'}
|
||||
LLM 분석 실패율이 높습니다 ({briefing.llm_failures}/{briefing.llm_calls}, {fallbackPct}%). 일부 토픽이 원문 묶음으로 표시됩니다.
|
||||
{:else}
|
||||
일부 토픽 LLM 실패 ({briefing.llm_failures}/{briefing.llm_calls}). 다른 토픽은 정상 분석되었습니다.
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- ═══ 본문 ═══ -->
|
||||
{#if loading}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4 text-sm text-dim">불러오는 중…</div>
|
||||
{:else if errorMsg}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4 text-sm text-text">{errorMsg}</div>
|
||||
{:else if briefing}
|
||||
{#if briefing.status === 'empty'}
|
||||
<div class="bg-surface border border-default rounded-lg p-5 mt-4">
|
||||
<p class="text-sm text-text">오늘 새벽({briefing.briefing_date}) 다국 비교 가능한 토픽이 없습니다.</p>
|
||||
<p class="mt-2 text-xs text-dim">(수집 뉴스 0건 또는 2개국 이상 다룬 주제 없음)</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 리드 토픽 (전체 너비, 관점 2열) -->
|
||||
{#if leadTopic}
|
||||
{@render topicCard(leadTopic, true)}
|
||||
{/if}
|
||||
<!-- 나머지 토픽 (2열 그리드) -->
|
||||
{#if restTopics.length > 0}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
{#each restTopics as topic (topic.id)}
|
||||
{@render topicCard(topic, false)}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet topicCard(topic, isLead)}
|
||||
<article class="bg-surface border rounded-lg overflow-hidden relative transition-opacity
|
||||
{isLead ? 'mt-4' : ''}
|
||||
{topic.highlighted ? 'border-accent ring-2 ring-accent/25' : 'border-default'}
|
||||
{topic.is_read ? 'opacity-50 hover:opacity-80' : ''}">
|
||||
{#if topic.is_read}
|
||||
<span class="absolute top-3 right-[88px] text-[10px] font-mono font-bold tracking-widest text-error border border-error rounded px-1.5 py-0.5 -rotate-6 opacity-70 pointer-events-none uppercase select-none">읽음</span>
|
||||
{/if}
|
||||
<!-- head -->
|
||||
<div class="flex items-start gap-3.5 px-5 pt-4 pb-3.5 border-b border-default">
|
||||
<div class="nws-serif font-extrabold leading-none text-text shrink-0 text-center pt-0.5 min-w-[42px]
|
||||
{topic.highlighted ? 'text-white bg-accent rounded-md px-1 py-1.5' : ''} {isLead ? 'text-3xl' : 'text-2xl'}">{folio(topic.topic_rank)}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-[11px] tracking-wide uppercase text-accent-hover font-bold mb-1">
|
||||
{topic.topic_label}{#if topic.llm_fallback_used}<span class="text-dim ml-1 normal-case">(원문 묶음)</span>{/if}
|
||||
</div>
|
||||
<div class="nws-serif font-bold leading-tight text-text tracking-tight {isLead ? 'text-[23px]' : 'text-[19px]'}">{topic.headline}</div>
|
||||
<div class="inline-flex items-center gap-1.5 mt-2 text-xs text-dim font-mono">
|
||||
<span>{topic.country_count}개국</span><span class="w-1 h-1 rounded-full bg-faint"></span><span>{topic.article_count}건</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
<button type="button" onclick={() => toggleHighlight(topic)} aria-label="별표 토글" title={topic.highlighted ? '별표 해제' : '별표'}
|
||||
class="w-[34px] h-[30px] rounded-md border flex items-center justify-center transition-colors
|
||||
{topic.highlighted ? 'bg-accent border-accent text-white' : 'bg-bg border-default text-dim hover:text-text hover:bg-surface-hover'}">★</button>
|
||||
<button type="button" onclick={() => toggleRead(topic)} aria-label="읽음 토글" title={topic.is_read ? '읽지 않음으로' : '읽음 처리'}
|
||||
class="w-[34px] h-[30px] rounded-md border flex items-center justify-center text-xs transition-colors
|
||||
{topic.is_read ? 'bg-accent/15 border-accent text-accent-hover' : 'bg-bg border-default text-dim hover:text-text hover:bg-surface-hover'}">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body -->
|
||||
<div class="px-5 pt-4 pb-4.5">
|
||||
{#if topic.country_perspectives.length > 0}
|
||||
<div class="nws-rule font-mono text-[10px] tracking-wider uppercase text-faint flex items-center gap-2 mb-2">국가별 관점</div>
|
||||
<div class="grid gap-2.5 {isLead ? 'lg:grid-cols-2' : 'grid-cols-1'}">
|
||||
{#each topic.country_perspectives as cp}
|
||||
<div class="border-l-[3px] border-border-strong pl-3 py-0.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span class="font-mono text-[10.5px] font-extrabold tracking-wide text-white rounded px-1.5 py-0.5 {countryChip(cp.country)}">{countryLabel(cp.country)}</span>
|
||||
{#if cp.article_ids.length > 0}
|
||||
<span class="inline-flex gap-1.5 flex-wrap">
|
||||
{#each cp.article_ids as id}
|
||||
<a href={`/documents/${id}`} class="font-mono text-[11px] text-accent-hover bg-accent/12 rounded px-1.5 py-px border border-transparent hover:border-accent transition-colors">#{id}</a>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-[13.5px] text-text leading-relaxed">{cp.summary}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.divergences.length > 0 || topic.convergences.length > 0}
|
||||
<div class="grid gap-2.5 mt-3.5 {isLead && topic.divergences.length > 0 && topic.convergences.length > 0 ? 'lg:grid-cols-2' : 'grid-cols-1'}">
|
||||
{#if topic.divergences.length > 0}
|
||||
<div class="rounded-lg px-3.5 py-3 text-[13px] leading-relaxed bg-error/[0.06] border border-error/20">
|
||||
<span class="block font-mono text-[10px] font-bold tracking-wide uppercase mb-1.5 text-error">차이</span>
|
||||
<span class="text-text">{topic.divergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if topic.convergences.length > 0}
|
||||
<div class="rounded-lg px-3.5 py-3 text-[13px] leading-relaxed bg-accent/12 border border-accent/25">
|
||||
<span class="block font-mono text-[10px] font-bold tracking-wide uppercase mb-1.5 text-accent-hover">공통</span>
|
||||
<span class="text-text">{topic.convergences.join(' · ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.key_quotes.length > 0}
|
||||
<div class="mt-3.5 flex flex-col gap-2.5">
|
||||
{#each topic.key_quotes as q}
|
||||
<div class="nws-quote relative pl-6">
|
||||
<div class="nws-serif italic text-[15px] leading-snug text-text">{q.quote}</div>
|
||||
<div class="text-[11px] text-dim font-mono mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
{#if q.country}<span class="text-[9.5px] font-extrabold tracking-wide text-white rounded px-1.5 py-0.5 {countryChip(q.country)}">{countryLabel(q.country)}</span>{/if}
|
||||
<span class="font-bold text-text">{q.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topic.historical_context}
|
||||
<div class="mt-3.5 px-3 py-2.5 rounded-md bg-bg border border-default text-[12.5px] text-dim leading-relaxed">
|
||||
<span class="font-mono text-[10px] font-bold tracking-wide uppercase text-faint mr-1.5">지난 흐름</span>{topic.historical_context}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-dim">
|
||||
{#if briefing}
|
||||
{briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽
|
||||
{:else}
|
||||
매일 KST 자정~05:00 누적 뉴스를 주제별로 다국 비교 분석합니다.
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
</article>
|
||||
{/snippet}
|
||||
|
||||
{#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>
|
||||
{: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.id)}
|
||||
<div class:opacity-60={topic.is_read}>
|
||||
<Card class={topic.highlighted ? "ring-2 ring-yellow-400" : ""}>
|
||||
<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 class="flex flex-col items-end gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleHighlight(topic)}
|
||||
class="text-base leading-none px-1.5 py-0.5 rounded hover:bg-surface"
|
||||
class:text-yellow-500={topic.highlighted}
|
||||
class:text-faint={!topic.highlighted}
|
||||
title={topic.highlighted ? '하이라이트 해제' : '하이라이트'}
|
||||
aria-label="하이라이트 토글"
|
||||
>★</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleRead(topic)}
|
||||
class="text-xs px-1.5 py-0.5 rounded border border-default hover:bg-surface"
|
||||
title={topic.is_read ? '읽지 않음으로' : '읽음 처리'}
|
||||
aria-label="읽음 토글"
|
||||
>{topic.is_read ? '✓읽음' : '읽음'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.nws-serif { font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif; }
|
||||
.nws-rule::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||
.nws-quote::before {
|
||||
content: "\201C"; font-family: Georgia, serif; font-size: 36px; line-height: 0;
|
||||
color: var(--accent); position: absolute; left: 0; top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user