feat: 뉴스 전용 뷰어 + 카드 구분 + 설정 UI

- DocumentViewer: source_channel=news → article 전용 뷰어
  (제목/소스/날짜/요약/원문 링크 rel=noopener)
- DocumentCard: 뉴스 카드에 📰 아이콘
- settings: 뉴스 소스 관리 (목록/추가/삭제/토글/수집/마지막 시간)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-06 13:55:49 +09:00
parent d03fa0df37
commit 2b457a8305
3 changed files with 179 additions and 18 deletions

View File

@@ -96,6 +96,9 @@
<!-- 우측 메타 -->
<div class="shrink-0 flex flex-col items-end gap-1 text-[10px]">
{#if doc.source_channel === 'news' && doc.edit_url}
<span class="text-blue-400">📰</span>
{/if}
{#if doc.score !== undefined}
<span class="text-[var(--accent)] font-medium">{(doc.score * 100).toFixed(0)}%</span>
{/if}

View File

@@ -62,7 +62,7 @@
loading = true;
try {
fullDoc = await api(`/documents/${id}`);
viewerType = getViewerType(fullDoc.file_format);
viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format);
// Markdown: extracted_text 없으면 원본 파일 직접 가져오기
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
@@ -232,6 +232,34 @@
class="px-3 py-1.5 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)]"
>AutoCAD Web에서 열기</a>
</div>
{:else if viewerType === 'article'}
<!-- 뉴스 전용 뷰어 -->
<div class="p-5 max-w-3xl mx-auto">
<h1 class="text-lg font-bold mb-2">{fullDoc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
{#if fullDoc.ai_tags?.length}
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
<span class="px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{tag.replace('News/', '')}</span>
{/each}
{/if}
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div class="markdown-body mb-6">
{@html renderMd(fullDoc.extracted_text || '')}
</div>
<div class="flex items-center gap-3 pt-4 border-t border-[var(--border)]">
{#if fullDoc.edit_url}
<a
href={fullDoc.edit_url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
>
<ExternalLink size={14} /> 원문 보기
</a>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-[var(--text-dim)]">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>

View File

@@ -1,4 +1,5 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { user } from '$lib/stores/auth';
@@ -8,6 +9,20 @@
let confirmPassword = '';
let changing = false;
// 뉴스 소스
let sources = $state([]);
let loadingSources = $state(true);
let collecting = $state(false);
let newSource = $state({ name: '', feed_url: '', category: '', language: 'ko', feed_type: 'rss', country: '' });
let showAddForm = $state(false);
onMount(async () => {
try {
sources = await api('/news/sources');
} catch (e) {}
loadingSources = false;
});
async function changePassword() {
if (newPassword !== confirmPassword) {
addToast('error', '새 비밀번호가 일치하지 않습니다');
@@ -17,32 +32,74 @@
addToast('error', '비밀번호는 8자 이상이어야 합니다');
return;
}
changing = true;
try {
await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
addToast('success', '비밀번호가 변경되었습니다');
currentPassword = '';
newPassword = '';
confirmPassword = '';
currentPassword = ''; newPassword = ''; confirmPassword = '';
} catch (err) {
addToast('error', err.detail || '비밀번호 변경 실패');
} finally { changing = false; }
}
async function toggleSource(source) {
try {
await api(`/news/sources/${source.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !source.enabled }),
});
source.enabled = !source.enabled;
sources = [...sources];
} catch (e) {
addToast('error', '변경 실패');
}
}
async function deleteSource(id) {
try {
await api(`/news/sources/${id}`, { method: 'DELETE' });
sources = sources.filter(s => s.id !== id);
addToast('success', '삭제됨');
} catch (e) {
addToast('error', '삭제 실패');
}
}
async function addSource() {
try {
const created = await api('/news/sources', {
method: 'POST',
body: JSON.stringify(newSource),
});
sources = [...sources, created];
showAddForm = false;
newSource = { name: '', feed_url: '', category: '', language: 'ko', feed_type: 'rss', country: '' };
addToast('success', '소스 추가됨');
} catch (e) {
addToast('error', '추가 실패');
}
}
async function collectNow() {
collecting = true;
try {
await api('/news/collect', { method: 'POST' });
addToast('success', '뉴스 수집 시작됨');
} catch (e) {
addToast('error', '수집 실패');
} finally {
changing = false;
setTimeout(() => collecting = false, 5000);
}
}
</script>
<div class="p-4 lg:p-6">
<div class="max-w-lg mx-auto">
<div class="max-w-2xl mx-auto space-y-6">
<!-- 계정 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 mb-6">
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h2 class="text-lg font-semibold mb-3">계정 정보</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
@@ -63,21 +120,94 @@
<h2 class="text-lg font-semibold mb-3">비밀번호 변경</h2>
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">현재 비밀번호</label>
<input type="password" bind:value={currentPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
<label for="pw-current" class="block text-sm text-[var(--text-dim)] mb-1">현재 비밀번호</label>
<input id="pw-current" type="password" bind:value={currentPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호</label>
<input type="password" bind:value={newPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
<label for="pw-new" class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호</label>
<input id="pw-new" type="password" bind:value={newPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<div>
<label class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호 확인</label>
<input type="password" bind:value={confirmPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
<label for="pw-confirm" class="block text-sm text-[var(--text-dim)] mb-1">새 비밀번호 확인</label>
<input id="pw-confirm" type="password" bind:value={confirmPassword} class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] outline-none focus:border-[var(--accent)]" />
</div>
<button type="submit" disabled={changing} class="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg disabled:opacity-50">
{changing ? '변경 중...' : '비밀번호 변경'}
</button>
</form>
</div>
<!-- 뉴스 소스 관리 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">뉴스 소스</h2>
<div class="flex gap-2">
<button
onclick={collectNow}
disabled={collecting}
class="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
>{collecting ? '수집 중...' : '지금 수집'}</button>
<button
onclick={() => showAddForm = !showAddForm}
class="px-3 py-1.5 text-xs border border-[var(--border)] rounded-lg text-[var(--text-dim)] hover:text-[var(--text)]"
>{showAddForm ? '취소' : '+ 추가'}</button>
</div>
</div>
<!-- 추가 폼 -->
{#if showAddForm}
<form onsubmit={(e) => { e.preventDefault(); addSource(); }} class="mb-4 p-3 bg-[var(--bg)] rounded-lg space-y-2">
<div class="grid grid-cols-2 gap-2">
<input bind:value={newSource.name} placeholder="이름" class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded text-sm text-[var(--text)]" />
<input bind:value={newSource.feed_url} placeholder="RSS URL" class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded text-sm text-[var(--text)]" />
<input bind:value={newSource.category} placeholder="분야" class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded text-sm text-[var(--text)]" />
<select bind:value={newSource.language} class="px-2 py-1.5 bg-[var(--surface)] border border-[var(--border)] rounded text-sm text-[var(--text)]">
<option value="ko">한국어</option>
<option value="en">English</option>
<option value="ja">日本語</option>
<option value="fr">Français</option>
<option value="zh">中文</option>
<option value="de">Deutsch</option>
</select>
</div>
<button type="submit" class="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded">추가</button>
</form>
{/if}
<!-- 소스 목록 -->
{#if loadingSources}
<div class="text-sm text-[var(--text-dim)]">로딩 중...</div>
{:else if sources.length === 0}
<div class="text-sm text-[var(--text-dim)]">등록된 소스가 없습니다</div>
{:else}
<div class="space-y-1">
{#each sources as source}
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-[var(--bg)] text-sm">
<div class="flex items-center gap-2 min-w-0">
<button
onclick={() => toggleSource(source)}
class="w-8 h-4 rounded-full transition-colors {source.enabled ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'}"
>
<div class="w-3 h-3 rounded-full bg-white transition-transform {source.enabled ? 'translate-x-4' : 'translate-x-0.5'}"></div>
</button>
<span class="truncate {source.enabled ? '' : 'text-[var(--text-dim)]'}">{source.name}</span>
<span class="text-[10px] text-[var(--text-dim)]">{source.category}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if source.last_fetched_at}
<span class="text-[10px] text-[var(--text-dim)]">
{new Date(source.last_fetched_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
{/if}
<button
onclick={() => deleteSource(source.id)}
class="text-[10px] text-[var(--text-dim)] hover:text-[var(--error)]"
>삭제</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>