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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user