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:
@@ -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
26
frontend/src/app.css
Normal 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
133
frontend/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
55
frontend/src/lib/stores/auth.ts
Normal file
55
frontend/src/lib/stores/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
frontend/src/lib/stores/ui.ts
Normal file
27
frontend/src/lib/stores/ui.ts
Normal 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));
|
||||
}
|
||||
57
frontend/src/routes/+layout.svelte
Normal file
57
frontend/src/routes/+layout.svelte
Normal 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>
|
||||
@@ -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)]">
|
||||
☰
|
||||
</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>
|
||||
|
||||
181
frontend/src/routes/documents/+page.svelte
Normal file
181
frontend/src/routes/documents/+page.svelte
Normal 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>
|
||||
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>
|
||||
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>
|
||||
89
frontend/src/routes/login/+page.svelte
Normal file
89
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
89
frontend/src/routes/settings/+page.svelte
Normal file
89
frontend/src/routes/settings/+page.svelte
Normal 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
7
frontend/vite.config.js
Normal 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()],
|
||||
});
|
||||
Reference in New Issue
Block a user