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:
Hyungi Ahn
2026-04-03 06:46:19 +09:00
parent 46537ee11a
commit cfa95ff031
19 changed files with 1380 additions and 41 deletions

View File

@@ -1,14 +1,107 @@
<script>
// TODO: Phase 4에서 대시보드 위젯 구현
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { isAuthenticated, user, logout } from '$lib/stores/auth';
import { sidebarOpen, addToast } from '$lib/stores/ui';
let dashboard = null;
let loading = true;
onMount(async () => {
try {
dashboard = await api('/dashboard/');
} catch (err) {
addToast('error', '대시보드 로딩 실패');
} finally {
loading = false;
}
});
</script>
<h1>hyungi Document Server</h1>
<p>PKM 대시보드 — Phase 4에서 구현 예정</p>
<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">
<button onclick={() => sidebarOpen.update(v => !v)} class="text-[var(--text-dim)] hover:text-[var(--text)]">
&#9776;
</button>
<h1 class="text-lg font-semibold">hyungi Document Server</h1>
</div>
<div class="flex items-center gap-4">
<a href="/documents" class="text-sm text-[var(--text-dim)] hover:text-[var(--text)]">문서</a>
<a href="/inbox" class="text-sm text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
<span class="text-sm text-[var(--text-dim)]">{$user?.username}</span>
<button onclick={() => { logout(); goto('/login'); }} class="text-sm text-[var(--error)] hover:underline">로그아웃</button>
</div>
</nav>
<section>
<h2>시스템 상태</h2>
<ul>
<li>FastAPI: <a href="/api/health">헬스체크</a></li>
<li>API 문서: <a href="/docs">OpenAPI</a></li>
</ul>
</section>
<!-- 대시보드 -->
<div class="max-w-6xl mx-auto p-6">
<h2 class="text-xl font-bold mb-6">대시보드</h2>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)] animate-pulse h-28"></div>
{/each}
</div>
{:else if dashboard}
<!-- 위젯 그리드 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- 전체 문서 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">전체 문서</p>
<p class="text-3xl font-bold mt-1">{dashboard.total_documents}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 +{dashboard.today_added}</p>
</div>
<!-- Inbox -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">Inbox 미분류</p>
<p class="text-3xl font-bold mt-1" class:text-[var(--warning)]={dashboard.inbox_count > 0}>{dashboard.inbox_count}</p>
{#if dashboard.inbox_count > 0}
<a href="/inbox" class="text-xs text-[var(--accent)] hover:underline mt-1 inline-block">분류하기</a>
{/if}
</div>
<!-- 법령 알림 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">법령 알림</p>
<p class="text-3xl font-bold mt-1">{dashboard.law_alerts}</p>
<p class="text-xs text-[var(--text-dim)] mt-1">오늘 변경</p>
</div>
<!-- 파이프라인 -->
<div class="bg-[var(--surface)] rounded-xl p-5 border border-[var(--border)]">
<p class="text-sm text-[var(--text-dim)]">파이프라인</p>
{#if dashboard.failed_count > 0}
<p class="text-3xl font-bold mt-1 text-[var(--error)]">{dashboard.failed_count} 실패</p>
{:else}
<p class="text-3xl font-bold mt-1 text-[var(--success)]">정상</p>
{/if}
</div>
</div>
<!-- 최근 문서 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-3">최근 문서</h3>
{#if dashboard.recent_documents.length > 0}
<div class="space-y-2">
{#each dashboard.recent_documents as doc}
<a href="/documents/{doc.id}" class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[var(--bg)] transition-colors">
<div class="flex items-center gap-3">
<span class="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase">{doc.file_format}</span>
<span class="text-sm truncate max-w-md">{doc.title || '제목 없음'}</span>
</div>
<span class="text-xs text-[var(--text-dim)]">{doc.ai_domain || ''}</span>
</a>
{/each}
</div>
{:else}
<p class="text-sm text-[var(--text-dim)]">문서가 없습니다</p>
{/if}
</div>
{/if}
</div>
</div>