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:
150
frontend/src/routes/documents/[id]/+page.svelte
Normal file
150
frontend/src/routes/documents/[id]/+page.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
import { marked } from 'marked';
|
||||
|
||||
let doc = null;
|
||||
let loading = true;
|
||||
|
||||
$: docId = $page.params.id;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
doc = await api(`/documents/${docId}`);
|
||||
} catch (err) {
|
||||
addToast('error', '문서를 찾을 수 없습니다');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 포맷별 뷰어 타입
|
||||
$: viewerType = doc ? getViewerType(doc.file_format) : 'none';
|
||||
|
||||
function getViewerType(format) {
|
||||
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
|
||||
if (format === 'pdf') return 'pdf';
|
||||
if (['hwp', 'hwpx'].includes(format)) return 'hwp-markdown';
|
||||
if (['odoc', 'osheet'].includes(format)) return 'synology';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(format)) return 'image';
|
||||
return 'unsupported';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<nav class="flex items-center gap-2 px-6 py-3 border-b border-[var(--border)] bg-[var(--surface)] text-sm">
|
||||
<a href="/" class="text-[var(--text-dim)] hover:text-[var(--text)]">PKM</a>
|
||||
<span class="text-[var(--text-dim)]">/</span>
|
||||
<a href="/documents" class="text-[var(--text-dim)] hover:text-[var(--text)]">문서</a>
|
||||
<span class="text-[var(--text-dim)]">/</span>
|
||||
<span class="truncate max-w-md">{doc?.title || '로딩...'}</span>
|
||||
</nav>
|
||||
|
||||
{#if loading}
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<div class="bg-[var(--surface)] rounded-xl p-6 border border-[var(--border)] animate-pulse h-96"></div>
|
||||
</div>
|
||||
{:else if doc}
|
||||
<div class="max-w-6xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 뷰어 (2/3) -->
|
||||
<div class="lg:col-span-2 bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 min-h-[500px]">
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<div class="prose prose-invert prose-sm max-w-none">
|
||||
{@html marked(doc.extracted_text || '*텍스트 추출 대기 중*')}
|
||||
</div>
|
||||
{:else if viewerType === 'pdf'}
|
||||
<iframe
|
||||
src="/documents/file/{doc.id}"
|
||||
class="w-full h-[80vh] rounded"
|
||||
title={doc.title}
|
||||
></iframe>
|
||||
{:else if viewerType === 'image'}
|
||||
<img src="/documents/file/{doc.id}" alt={doc.title} class="max-w-full rounded" />
|
||||
{:else if viewerType === 'synology'}
|
||||
<div class="text-center py-10">
|
||||
<p class="text-[var(--text-dim)] mb-4">Synology Office 문서</p>
|
||||
<a
|
||||
href="https://ds1525.hyungi.net:15001/oo/r/{doc.id}"
|
||||
target="_blank"
|
||||
class="px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)]"
|
||||
>
|
||||
새 창에서 열기
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-10">
|
||||
<p class="text-[var(--text-dim)] mb-2">이 문서 형식은 인앱 미리보기를 지원하지 않습니다</p>
|
||||
<p class="text-xs text-[var(--text-dim)]">포맷: {doc.file_format}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 메타데이터 패널 (1/3) -->
|
||||
<div class="space-y-4">
|
||||
<!-- 기본 정보 -->
|
||||
<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>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">포맷</dt>
|
||||
<dd class="uppercase">{doc.file_format}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">크기</dt>
|
||||
<dd>{doc.file_size ? (doc.file_size / 1024).toFixed(1) + ' KB' : '-'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">도메인</dt>
|
||||
<dd>{doc.ai_domain || '미분류'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">출처</dt>
|
||||
<dd>{doc.source_channel || '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- AI 요약 -->
|
||||
{#if doc.ai_summary}
|
||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">AI 요약</h3>
|
||||
<p class="text-sm leading-relaxed">{doc.ai_summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 태그 -->
|
||||
{#if doc.ai_tags?.length > 0}
|
||||
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-dim)] mb-2">태그</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each doc.ai_tags as tag}
|
||||
<span class="px-2 py-0.5 bg-[var(--bg)] rounded text-xs text-[var(--accent)]">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 가공 이력 -->
|
||||
<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>
|
||||
<dl class="space-y-2 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">텍스트 추출</dt>
|
||||
<dd>{doc.extracted_at ? new Date(doc.extracted_at).toLocaleDateString('ko') : '대기'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">AI 분류</dt>
|
||||
<dd>{doc.ai_processed_at ? new Date(doc.ai_processed_at).toLocaleDateString('ko') : '대기'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-[var(--text-dim)]">벡터 임베딩</dt>
|
||||
<dd>{doc.embedded_at ? new Date(doc.embedded_at).toLocaleDateString('ko') : '대기'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user