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

@@ -10,7 +10,13 @@
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.0.0",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"dependencies": {
"lucide-svelte": "^0.400.0",
"marked": "^15.0.0"
}
}

26
frontend/src/app.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--text-dim: #8b8d98;
--accent: #6c8aff;
--accent-hover: #859dff;
--error: #f5564e;
--success: #4ade80;
--warning: #fbbf24;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
}
/* 스크롤바 */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }

133
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* API fetch 래퍼
*
* - access token: 메모리 변수
* - refresh token: HttpOnly cookie (서버가 관리)
* - refresh 중복 방지: isRefreshing 플래그 + 대기 큐
* - 401 retry: 1회만, 실패 시 강제 logout
*/
const API_BASE = '/api';
let accessToken: string | null = null;
// refresh 큐
let isRefreshing = false;
let refreshQueue: Array<{
resolve: (token: string) => void;
reject: (err: Error) => void;
}> = [];
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
async function refreshAccessToken(): Promise<string> {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include', // cookie 전송
});
if (!res.ok) {
throw new Error('refresh failed');
}
const data = await res.json();
accessToken = data.access_token;
return data.access_token;
}
function processRefreshQueue(error: Error | null, token: string | null) {
refreshQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
refreshQueue = [];
}
async function handleTokenRefresh(): Promise<string> {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
const token = await refreshAccessToken();
processRefreshQueue(null, token);
return token;
} catch (err) {
const error = err instanceof Error ? err : new Error('refresh failed');
processRefreshQueue(error, null);
// 강제 logout
accessToken = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw error;
} finally {
isRefreshing = false;
}
}
export type ApiError = {
status: number;
detail: string;
};
export async function api<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
// FormData일 때는 Content-Type 자동 설정
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
// 401 → refresh 1회 시도
if (res.status === 401 && accessToken) {
try {
await handleTokenRefresh();
headers['Authorization'] = `Bearer ${accessToken}`;
const retryRes = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!retryRes.ok) {
const err = await retryRes.json().catch(() => ({ detail: 'Unknown error' }));
throw { status: retryRes.status, detail: err.detail || retryRes.statusText };
}
return retryRes.json();
} catch {
throw { status: 401, detail: '인증이 만료되었습니다' };
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw { status: res.status, detail: err.detail || res.statusText } as ApiError;
}
// 204 No Content
if (res.status === 204) return {} as T;
return res.json();
}

View File

@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
import { api, setAccessToken } from '$lib/api';
interface User {
id: number;
username: string;
is_active: boolean;
totp_enabled: boolean;
last_login_at: string | null;
}
export const user = writable<User | null>(null);
export const isAuthenticated = writable(false);
export async function login(username: string, password: string, totp_code?: string) {
const data = await api<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password, totp_code: totp_code || undefined }),
});
setAccessToken(data.access_token);
await fetchUser();
}
export async function fetchUser() {
try {
const data = await api<User>('/auth/me');
user.set(data);
isAuthenticated.set(true);
} catch {
user.set(null);
isAuthenticated.set(false);
}
}
export async function logout() {
try {
await api('/auth/logout', { method: 'POST' });
} catch { /* ignore */ }
setAccessToken(null);
user.set(null);
isAuthenticated.set(false);
}
export async function tryRefresh() {
try {
const data = await api<{ access_token: string }>('/auth/refresh', {
method: 'POST',
});
setAccessToken(data.access_token);
await fetchUser();
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
export const sidebarOpen = writable(true);
export const selectedDocId = writable<number | null>(null);
// Toast 시스템
interface Toast {
id: number;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
}
let toastId = 0;
export const toasts = writable<Toast[]>([]);
export function addToast(type: Toast['type'], message: string, duration = 5000) {
const id = ++toastId;
toasts.update(t => [...t, { id, type, message }]);
if (duration > 0) {
setTimeout(() => removeToast(id), duration);
}
return id;
}
export function removeToast(id: number) {
toasts.update(t => t.filter(toast => toast.id !== id));
}

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>

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>

View File

@@ -0,0 +1,181 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
let documents = [];
let total = 0;
let loading = true;
let searchQuery = '';
let searchMode = 'hybrid';
let currentPage = 1;
let searchResults = null;
let debounceTimer;
// URL에서 초기 상태 복원
onMount(() => {
const params = $page.url.searchParams;
searchQuery = params.get('q') || '';
searchMode = params.get('mode') || 'hybrid';
currentPage = parseInt(params.get('page') || '1');
if (searchQuery) doSearch(); else loadDocuments();
});
async function loadDocuments() {
loading = true;
searchResults = null;
try {
const data = await api(`/documents/?page=${currentPage}&page_size=20`);
documents = data.items;
total = data.total;
} catch (err) {
addToast('error', '문서 목록 로딩 실패');
} finally {
loading = false;
}
}
function handleSearchInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (searchQuery.trim()) {
currentPage = 1;
doSearch();
} else {
loadDocuments();
updateUrl();
}
}, 300);
}
async function doSearch() {
loading = true;
updateUrl();
try {
const data = await api(`/search/?q=${encodeURIComponent(searchQuery)}&mode=${searchMode}&limit=50`);
searchResults = data.results;
total = data.total;
} catch (err) {
addToast('error', '검색 실패');
searchResults = [];
} finally {
loading = false;
}
}
function updateUrl() {
const params = new URLSearchParams();
if (searchQuery) params.set('q', searchQuery);
if (searchMode !== 'hybrid') params.set('mode', searchMode);
if (currentPage > 1) params.set('page', String(currentPage));
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { replaceState: true, noScroll: true });
}
function changePage(p) {
currentPage = p;
if (searchQuery) doSearch(); else loadDocuments();
updateUrl();
}
$: totalPages = Math.ceil(total / 20);
$: items = searchResults || documents;
</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>문서</span>
</div>
<a href="/inbox" class="text-sm text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
</nav>
<div class="max-w-6xl mx-auto p-6">
<!-- 검색바 -->
<div class="flex gap-2 mb-6">
<input
data-search-input
type="text"
bind:value={searchQuery}
oninput={handleSearchInput}
placeholder="검색어 입력... (/ 키로 포커스)"
class="flex-1 px-4 py-2.5 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none"
/>
<select
bind:value={searchMode}
onchange={() => { if (searchQuery) doSearch(); }}
class="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-[var(--text)] text-sm"
>
<option value="hybrid">하이브리드</option>
<option value="fts">전문검색</option>
<option value="trgm">부분매칭</option>
<option value="vector">의미검색</option>
</select>
</div>
<!-- 결과 -->
{#if loading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="bg-[var(--surface)] rounded-lg p-4 border border-[var(--border)] animate-pulse h-20"></div>
{/each}
</div>
{:else if items.length === 0}
<div class="text-center py-20 text-[var(--text-dim)]">
{#if searchQuery}
<p class="text-lg mb-2">'{searchQuery}'에 대한 결과가 없습니다</p>
<p class="text-sm">다른 검색어를 시도하거나 검색 모드를 변경해보세요</p>
{:else}
<p class="text-lg">등록된 문서가 없습니다</p>
{/if}
</div>
{:else}
<div class="space-y-2">
{#each items as doc}
<a
href="/documents/{doc.id}"
class="flex items-center justify-between p-4 bg-[var(--surface)] border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--text-dim)] uppercase shrink-0">{doc.file_format}</span>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{doc.title || '제목 없음'}</p>
{#if doc.ai_summary}
<p class="text-xs text-[var(--text-dim)] truncate mt-0.5">{doc.ai_summary?.slice(0, 100)}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-3 shrink-0 ml-4">
{#if doc.score !== undefined}
<span class="text-xs text-[var(--accent)]">{(doc.score * 100).toFixed(0)}%</span>
{/if}
<span class="text-xs text-[var(--text-dim)]">{doc.ai_domain || ''}</span>
</div>
</a>
{/each}
</div>
<!-- 페이지네이션 -->
{#if !searchResults && totalPages > 1}
<div class="flex justify-center gap-2 mt-6">
{#each Array(totalPages) as _, i}
<button
onclick={() => changePage(i + 1)}
class="px-3 py-1 rounded text-sm"
class:bg-[var(--accent)]={currentPage === i + 1}
class:text-white={currentPage === i + 1}
class:bg-[var(--surface)]={currentPage !== i + 1}
class:text-[var(--text-dim)]={currentPage !== i + 1}
>
{i + 1}
</button>
{/each}
</div>
{/if}
{/if}
</div>
</div>

View 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>

View 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>

View File

@@ -0,0 +1,89 @@
<script>
import { goto } from '$app/navigation';
import { login } from '$lib/stores/auth';
import { addToast } from '$lib/stores/ui';
let username = '';
let password = '';
let totpCode = '';
let needsTotp = false;
let loading = false;
let error = '';
async function handleLogin() {
error = '';
loading = true;
try {
await login(username, password, needsTotp ? totpCode : undefined);
goto('/');
} catch (err) {
if (err.detail?.includes('TOTP')) {
needsTotp = true;
error = 'TOTP 코드를 입력하세요';
} else {
error = err.detail || '로그인 실패';
}
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<h1 class="text-2xl font-bold mb-1">hyungi Document Server</h1>
<p class="text-[var(--text-dim)] text-sm mb-8">로그인</p>
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
<div>
<label for="username" class="block text-sm text-[var(--text-dim)] mb-1">아이디</label>
<input
id="username"
type="text"
bind:value={username}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none"
autocomplete="username"
/>
</div>
<div>
<label for="password" class="block text-sm text-[var(--text-dim)] mb-1">비밀번호</label>
<input
id="password"
type="password"
bind:value={password}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none"
autocomplete="current-password"
/>
</div>
{#if needsTotp}
<div>
<label for="totp" class="block text-sm text-[var(--text-dim)] mb-1">TOTP 코드</label>
<input
id="totp"
type="text"
bind:value={totpCode}
maxlength="6"
inputmode="numeric"
pattern="[0-9]*"
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-[var(--text)] focus:border-[var(--accent)] outline-none tracking-widest text-center text-lg"
autocomplete="one-time-code"
/>
</div>
{/if}
{#if error}
<p class="text-[var(--error)] text-sm">{error}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full py-2.5 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded-lg font-medium disabled:opacity-50 transition-colors"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<script>
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { user } from '$lib/stores/auth';
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let changing = false;
async function changePassword() {
if (newPassword !== confirmPassword) {
addToast('error', '새 비밀번호가 일치하지 않습니다');
return;
}
if (newPassword.length < 8) {
addToast('error', '비밀번호는 8자 이상이어야 합니다');
return;
}
changing = true;
try {
await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
addToast('success', '비밀번호가 변경되었습니다');
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (err) {
addToast('error', err.detail || '비밀번호 변경 실패');
} finally {
changing = false;
}
}
</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>
<span>설정</span>
</nav>
<div class="max-w-lg mx-auto p-6">
<!-- 계정 정보 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 mb-6">
<h2 class="text-lg font-semibold mb-3">계정 정보</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">아이디</dt>
<dd>{$user?.username}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--text-dim)]">2FA (TOTP)</dt>
<dd class={$user?.totp_enabled ? 'text-[var(--success)]' : 'text-[var(--text-dim)]'}>
{$user?.totp_enabled ? '활성' : '비활성'}
</dd>
</div>
</dl>
</div>
<!-- 비밀번호 변경 -->
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<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)]" />
</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)]" />
</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)]" />
</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>
</div>

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
});