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

@@ -0,0 +1,57 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/ui';
import '../app.css';
const PUBLIC_PATHS = ['/login', '/setup'];
onMount(async () => {
if (!$isAuthenticated) {
await tryRefresh();
}
});
$: {
if (!$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
goto('/login');
}
}
// 키보드 단축키
function handleKeydown(e) {
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault();
document.querySelector('[data-search-input]')?.focus();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if $isAuthenticated || PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))}
<slot />
{/if}
<!-- Toast 컨테이너 -->
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{#each $toasts as toast (toast.id)}
<div
class="px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 cursor-pointer"
class:bg-green-900={toast.type === 'success'}
class:bg-red-900={toast.type === 'error'}
class:bg-yellow-900={toast.type === 'warning'}
class:bg-blue-900={toast.type === 'info'}
class:text-green-200={toast.type === 'success'}
class:text-red-200={toast.type === 'error'}
class:text-yellow-200={toast.type === 'warning'}
class:text-blue-200={toast.type === 'info'}
role="alert"
onclick={() => removeToast(toast.id)}
>
{toast.message}
</div>
{/each}
</div>