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:
@@ -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