feat: 뉴스 전용 페이지 + 분류 격리 + 읽음 상태

- /news 전용 페이지: 신문사 필터, 읽지않음 필터, 시간순 리스트, 미리보기
- 뉴스 분류 격리: ai_domain='News', classify 제거, embed만 등록
- is_read: 클릭 시 자동 읽음, 전체 읽음 API
- documents 목록에서 뉴스 제외 (source_channel != 'news')
- nav에 뉴스 링크 추가
- GET /api/news/articles, POST /api/news/mark-all-read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-06 14:16:00 +09:00
parent cd5f1c526d
commit 7ca3abf17c
7 changed files with 280 additions and 9 deletions

View File

@@ -76,6 +76,7 @@
<a href="/documents" class="text-xs hover:text-[var(--accent)]">문서</a>
</div>
<div class="flex items-center gap-3">
<a href="/news" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">뉴스</a>
<a href="/inbox" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
<a href="/settings" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">설정</a>
<button

View File

@@ -0,0 +1,198 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let articles = $state([]);
let total = $state(0);
let loading = $state(true);
let selectedArticle = $state(null);
let filterSource = $state('');
let showUnreadOnly = $state(false);
let sources = $state([]);
let currentPage = $state(1);
onMount(async () => {
try {
const srcData = await api('/news/sources');
// 신문사별 유니크
const names = new Set();
srcData.forEach(s => names.add(s.name.split(' ')[0]));
sources = [...names];
} 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');
const data = await api(`/news/articles?${params}`);
articles = data.items;
total = data.total;
} catch (err) {
addToast('error', '뉴스 로딩 실패');
} finally {
loading = false;
}
}
function selectArticle(article) {
selectedArticle = article;
if (!article.is_read) markRead(article);
}
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', '실패');
}
}
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}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
}
$effect(() => {
const _s = filterSource;
const _u = showUnreadOnly;
currentPage = 1;
loadArticles();
});
</script>
<div class="flex h-full">
<!-- 좌측 필터 -->
<div class="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={() => { filterSource = ''; }}
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 sources as src}
<button
onclick={() => { filterSource = src; }}
class="w-full text-left px-2 py-1.5 rounded text-sm mb-0.5 {filterSource === src ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
>{src}</button>
{/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>
</div>
<!-- 메인 -->
<div class="flex-1 flex flex-col min-h-0">
<!-- 상단 바 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
<span class="text-xs text-[var(--text-dim)]">{total}</span>
<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>
<!-- 상단: 기사 리스트 -->
<div class="overflow-y-auto {selectedArticle ? 'h-[40%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
{#if loading}
<div class="p-4 space-y-2">
{#each Array(5) as _}
<div class="h-12 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>
{:else}
{#each articles as article}
<button
onclick={() => selectArticle(article)}
class="w-full text-left px-4 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] transition-colors
{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 text-[10px] {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">
{article.is_read ? '○' : '●'}
</span>
<div class="min-w-0">
<p class="text-sm truncate {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
<div class="flex items-center gap-2 mt-0.5 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>
</div>
</button>
{/each}
{/if}
</div>
<!-- 하단: 기사 미리보기 -->
{#if selectedArticle}
<div class="flex-1 overflow-y-auto p-5 bg-[var(--surface)]">
<h2 class="text-lg font-bold mb-2">{selectedArticle.title}</h2>
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
<span>{selectedArticle.ai_sub_group}</span>
<span>·</span>
<span>{timeAgo(selectedArticle.created_at)}</span>
</div>
<div class="markdown-body mb-4">
{@html renderMd(selectedArticle.extracted_text || '')}
</div>
{#if selectedArticle.edit_url}
<a
href={selectedArticle.edit_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)] text-sm"
>원문 보기 →</a>
{/if}
</div>
{/if}
</div>
</div>