feat: implement Phase 4 SvelteKit frontend + backend enhancements
Backend: - Add dashboard API (today stats, inbox count, law alerts, pipeline status) - Add /api/documents/tree endpoint for sidebar domain/sub_group tree - Migrate auth to HttpOnly cookie for refresh token (XSS defense) - Add /api/auth/logout endpoint (cookie cleanup) - Register dashboard router in main.py Frontend (SvelteKit + Tailwind CSS v4): - api.ts: fetch wrapper with refresh queue pattern, 401 single retry, forced logout on refresh failure - Auth store: login/logout/refresh with memory-based access token - UI store: toast system, sidebar state - Login page with TOTP support - Dashboard with 4 stat widgets + recent documents - Document list with hybrid search (debounce, URL query state, mode select) - Document detail with format-aware viewer (markdown/PDF/HWP/Synology/fallback) - Metadata panel (AI summary, tags, processing history) - Inbox triage UI (batch select, confirm dialog, domain override) - Settings page (password change, TOTP status) Infrastructure: - Enable frontend service in docker-compose - Caddy path routing (/api/* → fastapi, / → frontend) + gzip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
frontend/src/routes/inbox/+page.svelte
Normal file
192
frontend/src/routes/inbox/+page.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
|
||||
let documents = [];
|
||||
let loading = true;
|
||||
let selected = new Set();
|
||||
|
||||
onMount(loadInbox);
|
||||
|
||||
async function loadInbox() {
|
||||
loading = true;
|
||||
try {
|
||||
// Inbox 파일만 필터
|
||||
const data = await api('/documents/?page_size=100');
|
||||
documents = data.items.filter(d => d.file_path?.startsWith('PKM/Inbox/'));
|
||||
} catch (err) {
|
||||
addToast('error', 'Inbox 로딩 실패');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selected.has(id)) selected.delete(id);
|
||||
else selected.add(id);
|
||||
selected = selected; // 반응성 트리거
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === documents.length) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(documents.map(d => d.id));
|
||||
}
|
||||
}
|
||||
|
||||
let approving = false;
|
||||
let showConfirm = false;
|
||||
|
||||
function startApprove() {
|
||||
if (selected.size === 0) {
|
||||
addToast('warning', '선택된 문서가 없습니다');
|
||||
return;
|
||||
}
|
||||
showConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
showConfirm = false;
|
||||
approving = true;
|
||||
let success = 0;
|
||||
const ids = [...selected];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
// AI 분류 결과 그대로 승인 (Inbox에서 이동은 classify_worker가 처리)
|
||||
const doc = documents.find(d => d.id === id);
|
||||
if (doc?.ai_domain) {
|
||||
await api(`/documents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source_channel: 'inbox_route' }),
|
||||
});
|
||||
success++;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
addToast('success', `${success}건 승인 완료`);
|
||||
selected = new Set();
|
||||
approving = false;
|
||||
loadInbox();
|
||||
}
|
||||
|
||||
async function updateDomain(id, domain) {
|
||||
try {
|
||||
await api(`/documents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ai_domain: domain }),
|
||||
});
|
||||
documents = documents.map(d => d.id === id ? { ...d, ai_domain: domain } : d);
|
||||
addToast('success', '도메인 변경됨');
|
||||
} catch {
|
||||
addToast('error', '변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
const DOMAINS = [
|
||||
'Knowledge/Philosophy',
|
||||
'Knowledge/Language',
|
||||
'Knowledge/Engineering',
|
||||
'Knowledge/Industrial_Safety',
|
||||
'Knowledge/Programming',
|
||||
'Knowledge/General',
|
||||
'Reference',
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<nav class="flex items-center justify-between px-6 py-3 border-b border-[var(--border)] bg-[var(--surface)]">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/" class="text-lg font-semibold hover:text-[var(--accent)]">PKM</a>
|
||||
<span class="text-[var(--text-dim)]">/</span>
|
||||
<span>Inbox</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-[var(--warning)] text-black">{documents.length}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick={toggleAll} class="px-3 py-1.5 text-xs bg-[var(--surface)] border border-[var(--border)] rounded-lg">
|
||||
{selected.size === documents.length ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
<button
|
||||
onclick={startApprove}
|
||||
disabled={approving || selected.size === 0}
|
||||
class="px-4 py-1.5 text-xs bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{approving ? '처리 중...' : `선택 승인 (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="bg-[var(--surface)] rounded-lg p-4 border border-[var(--border)] animate-pulse h-24"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
<div class="text-center py-20 text-[var(--text-dim)]">
|
||||
<p class="text-lg">Inbox가 비어 있습니다</p>
|
||||
<p class="text-sm mt-1">새 파일이 들어오면 자동으로 표시됩니다</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each documents as doc}
|
||||
<div class="flex items-start gap-3 p-4 bg-[var(--surface)] border border-[var(--border)] rounded-lg" class:border-[var(--accent)]={selected.has(doc.id)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(doc.id)}
|
||||
onchange={() => toggleSelect(doc.id)}
|
||||
class="mt-1 accent-[var(--accent)]"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
|
||||
<a href="/documents/{doc.id}" class="text-sm font-medium hover:text-[var(--accent)] truncate">{doc.title || '제목 없음'}</a>
|
||||
</div>
|
||||
{#if doc.ai_summary}
|
||||
<p class="text-xs text-[var(--text-dim)] truncate">{doc.ai_summary.slice(0, 120)}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-[var(--text-dim)]">AI 분류:</span>
|
||||
<select
|
||||
value={doc.ai_domain || ''}
|
||||
onchange={(e) => updateDomain(doc.id, e.target.value)}
|
||||
class="text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--text)]"
|
||||
>
|
||||
<option value="">미분류</option>
|
||||
{#each DOMAINS as d}
|
||||
<option value={d}>{d.replace('Knowledge/', '')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if doc.ai_tags?.length > 0}
|
||||
<div class="flex gap-1 ml-2">
|
||||
{#each doc.ai_tags.slice(0, 3) as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-[var(--bg)] rounded text-[var(--accent)]">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 확인 다이얼로그 -->
|
||||
{#if showConfirm}
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-2">{selected.size}건을 승인합니다</h3>
|
||||
<p class="text-sm text-[var(--text-dim)] mb-4">AI 분류 결과를 확정하고 Inbox에서 이동합니다.</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick={() => showConfirm = false} class="px-4 py-2 text-sm bg-[var(--bg)] border border-[var(--border)] rounded-lg">취소</button>
|
||||
<button onclick={confirmApprove} class="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded-lg">승인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user