From 57de6a1072691aaa7d5578098f6c06a4ac4387c7 Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 4 Jun 2026 02:55:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(digest):=20=ED=8E=B8=EC=A7=91=ED=98=95=201?= =?UTF-8?q?=EB=A9=B4=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20(=EC=95=881?= =?UTF-8?q?=20=EC=B1=84=ED=83=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /digest 단순 카드 → 신문 1면형 편집 뷰. 웜톤(크림+clay) self-contained — 앱 다크토큰 충돌 방지 위해 .digest-page 래퍼에 웜 팔레트 로컬 재정의. - 슬롯 매핑: ALL=전국가 imp 내림차순 / country=rank 오름차순 → lead·featured 2·sidebar 3·심층 grid, graceful 생략 - 국가 nav(ALL+국가별 주제수)·edition line·중요도 막대. date picker URL sync·기사 /documents/{id} 라우팅·국가사전 재사용 - 검정·이모지·외부폰트 0. 구현+적대적 리뷰 2(ok). docker build PASS Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/digest/+page.svelte | 1107 ++++++++++++++++++++--- 1 file changed, 959 insertions(+), 148 deletions(-) diff --git a/frontend/src/routes/digest/+page.svelte b/frontend/src/routes/digest/+page.svelte index 39f0293..c150f91 100644 --- a/frontend/src/routes/digest/+page.svelte +++ b/frontend/src/routes/digest/+page.svelte @@ -3,10 +3,8 @@ 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 Badge from '$lib/components/ui/Badge.svelte'; import Button from '$lib/components/ui/Button.svelte'; import { countryLabel } from '$lib/i18n/countries'; @@ -40,14 +38,11 @@ status: string; }; - const PREVIEW = 5; // topic 카드에서 기본 노출할 기사 수 - let digest = $state(null); let dates = $state([]); let loading = $state(true); let error = $state(null); let loadedDate = $state(null); // 어떤 date 파라미터로 로드했는지 (중복 로드 방지) - let expanded = $state>({}); // topic_rank → 기사 펼침 // URL = 단일 진실: ?date=, ?country= let urlDate = $derived($page.url.searchParams.get('date') ?? ''); @@ -64,7 +59,6 @@ async function loadDigest(dateStr: string) { loading = true; error = null; - expanded = {}; try { const path = dateStr ? `/digest?date=${dateStr}` : '/digest/latest'; digest = await api(path); @@ -90,15 +84,42 @@ onMount(loadDates); - // 현재 country: URL 우선, 없으면(또는 해당 날짜에 부재) 첫 국가로 graceful fallback + // 현재 country: '' = ALL(전체). URL 우선, 데이터에 없는 코드면 ALL 로 graceful fallback. let activeCountry = $derived.by(() => { const list = digest?.countries ?? []; + if (urlCountry === '') return ''; if (list.some((c) => c.country === urlCountry)) return urlCountry; - return list[0]?.country ?? ''; + return ''; // ALL fallback }); - let topics = $derived( - digest?.countries?.find((c) => c.country === activeCountry)?.topics ?? [], - ); + + // 편집형 정렬 리스트(t): country 슬롯 매핑의 단일 소스. + // - ALL(''): 전 국가 topics flatten → importance desc, 동률 시 article_count desc + // - 특정 country: 그 국가 topics → topic_rank asc + // 각 topic 에 country 코드를 동봉(렌더 시 칩 표기용). + type SlotTopic = Topic & { country: string }; + let sortedTopics = $derived.by(() => { + const list = digest?.countries ?? []; + if (activeCountry === '') { + const flat: SlotTopic[] = []; + for (const c of list) for (const t of c.topics) flat.push({ ...t, country: c.country }); + flat.sort((a, b) => { + if (b.importance_score !== a.importance_score) return b.importance_score - a.importance_score; + return b.article_count - a.article_count; + }); + return flat; + } + const c = list.find((x) => x.country === activeCountry); + if (!c) return []; + return [...c.topics] + .sort((a, b) => a.topic_rank - b.topic_rank) + .map((t) => ({ ...t, country: activeCountry })); + }); + + // 슬롯: lead=t[0], featured=t[1..2], sidebar=t[3..5], grid=t[6..] + let lead = $derived(sortedTopics[0]); + let featured = $derived(sortedTopics.slice(1, 3)); + let sidebar = $derived(sortedTopics.slice(3, 6)); + let grid = $derived(sortedTopics.slice(6)); // 날짜 네비게이션 (dates = 최신 내림차순) let currentDate = $derived(digest?.digest_date ?? urlDate ?? ''); @@ -117,7 +138,7 @@ setParams({ date: d }); } function pickCountry(c: string) { - setParams({ country: c }); + setParams({ country: c }); // '' → ALL (delete) } function older() { if (hasOlder) pickDate(dates[dateIdx + 1].digest_date); @@ -129,151 +150,941 @@ 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); + + function title(a: ArticleRef): string { + return a.title ?? '(제목 없음)'; } + function pct(imp: number): number { + return Math.max(0, Math.min(100, Math.round(imp * 100))); + } + // 편집일 한국어 표기 (예: 2026년 6월 4일 목요일) + let editionDateKo = $derived.by(() => { + const ds = digest?.digest_date; + if (!ds) return ''; + const [y, m, d] = ds.split('-').map((n) => parseInt(n, 10)); + if (!y || !m || !d) return ds; + const wd = ['일', '월', '화', '수', '목', '금', '토']; + const dt = new Date(Date.UTC(y, m - 1, d)); + return `${y}년 ${m}월 ${d}일 ${wd[dt.getUTCDay()]}요일`; + }); -
-
-
-

뉴스 다이제스트

- {#if digest} - - {digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제 · 총 {digest.total_articles}건 - - {/if} -
+
+
- -
- - - - -
-
+ +
+ + + +
+
+ - {#if loading} - - - - {:else if error} - - - - {:else if !digest || digest.countries.length === 0} - - {:else} - -
- {#each digest.countries as c (c.country)} - {@const lbl = countryLabel(c.country)} + + {#if digest && digest.countries.length > 0} +
- - {#if topics.length === 0} - - {:else} -
- {#each topics as t (t.topic_rank)} - -
-

- {t.topic_rank}. {t.topic_label} -

- {#if t.llm_fallback_used} - fallback - {/if} -
-

{t.summary}

-
- {t.article_count} articles · importance {t.importance_score.toFixed(2)} -
- - {#if t.articles.length > 0} - - {#if t.articles.length > PREVIEW && !expanded[t.topic_rank]} - - {:else if t.articles.length > PREVIEW} - - {/if} - {/if} -
+ {#each digest.countries as c (c.country)} + {@const lbl = countryLabel(c.country)} + {/each} -
+ {/if} - {/if} - + + +
+ + {#if loading} + +
+ +
+ + {:else if error} + + + + {:else if !digest || digest.countries.length === 0} + + {:else if sortedTopics.length === 0} + + {:else} + + +
+ Today's Front Page + + {editionDateKo} + + + {#if activeCountry === ''} + 중요도 상위 주제 우선 편집 · 전 {digest.total_countries}개국 수집 + {:else} + {countryLabel(activeCountry).ko} · 주제 순위 편집 + {/if} + +
+ + + {#if lead} + {@const lbl = countryLabel(lead.country)} +
+
+ Top Story + + {lead.country} {lbl.ko} + + + 관련기사 {lead.article_count}건 · 중요도 {lead.importance_score.toFixed(2)} + +
+

{lead.topic_label}

+ {#if lead.summary} +

{lead.summary}

+ {/if} + {#if lead.articles.length > 0} +
+ {#each lead.articles as a (a.id)} +
+ + {title(a)} +
+ {/each} +
+ {/if} +
+ 중요도 +
+
+
+ {lead.importance_score.toFixed(2)} +
+
+ {/if} + + + {#if featured.length > 0 || sidebar.length > 0} +
+ 주요 뉴스 +
+
+ +
+ {#each featured as t (t.country + ':' + t.topic_rank)} + {@const lbl = countryLabel(t.country)} +
+
+ {t.country} {lbl.ko} + 기사 {t.article_count}건 +
+

{t.topic_label}

+ {#if t.summary}

{t.summary}

{/if} + {#if t.articles.length > 0} +
+ {#each t.articles.slice(0, 2) as a (a.id)} + {title(a)} + {/each} +
+ {/if} +
+
+ imp {t.importance_score.toFixed(2)} +
+
+ {/each} + + {#if sidebar.length > 0} + + {/if} +
+ {/if} + + + {#if grid.length > 0} +
+ 심층 보도 +
+
+ +
+ {#each grid as t (t.country + ':' + t.topic_rank)} + {@const lbl = countryLabel(t.country)} +
+
+ {t.country} + {lbl.ko} + 기사 {t.article_count}건 +
+

{t.topic_label}

+ {#if t.summary}

{t.summary}

{/if} + {#if t.articles.length > 0} + {title(t.articles[0])} → + {/if} +
+
+ {/each} +
+ {/if} + + {/if} +
+ + + +