feat(ui): Phase B — sidebar drawer + SystemStatusDot + 키보드 nav
- +layout.svelte: 햄버거 → IconButton, 우측 nav → Button ghost, sidebar overlay → Drawer (uiState 단일 slot), Esc 글로벌 핸들러 ui.handleEscape() 위임 (5대 원칙 #2) - lib/stores/system.ts (신규): dashboardSummary writable + 60s 폴링, 단일 fetch를 SystemStatusDot(B)와 dashboard(C)가 공유 - SystemStatusDot.svelte (신규): 8px 도트 + tooltip, failed > 0 → error / pending > 10 → warning / 그 외 → success - Sidebar.svelte: 트리에 ArrowUp/Down 키보드 nav, 활성 도메인 row에 aria-current="page"
This commit is contained in:
@@ -66,6 +66,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));
|
let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));
|
||||||
|
|
||||||
|
// ArrowUp/Down 키보드 nav — 현재 펼쳐진 tree-row만 traverse
|
||||||
|
function handleTreeKeydown(e) {
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||||
|
const root = e.currentTarget;
|
||||||
|
const rows = Array.from(root.querySelectorAll('[data-tree-row]'));
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const idx = active ? rows.indexOf(active) : -1;
|
||||||
|
let next;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
next = idx < 0 ? 0 : Math.min(idx + 1, rows.length - 1);
|
||||||
|
} else {
|
||||||
|
next = idx <= 0 ? 0 : idx - 1;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
rows[next].focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="h-full flex flex-col bg-sidebar border-r border-default overflow-y-auto">
|
<aside class="h-full flex flex-col bg-sidebar border-r border-default overflow-y-auto">
|
||||||
@@ -91,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 트리 -->
|
<!-- 트리 -->
|
||||||
<nav class="flex-1 px-2 py-2">
|
<nav class="flex-1 px-2 py-2" onkeydown={handleTreeKeydown}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _}
|
||||||
<div class="h-8 bg-surface rounded-md animate-pulse mx-1 mb-1"></div>
|
<div class="h-8 bg-surface rounded-md animate-pulse mx-1 mb-1"></div>
|
||||||
@@ -123,6 +141,8 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => navigate(n.path)}
|
onclick={() => navigate(n.path)}
|
||||||
|
data-tree-row
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
|
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
|
||||||
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-text'}"
|
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
>
|
>
|
||||||
|
|||||||
89
frontend/src/lib/components/SystemStatusDot.svelte
Normal file
89
frontend/src/lib/components/SystemStatusDot.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// 시스템 상태 도트 — Phase B 신규.
|
||||||
|
// dashboardSummary store를 구독해 한 색상 + tooltip을 표시한다.
|
||||||
|
// 색상 규칙(우선순위 순):
|
||||||
|
// 1) failed_count > 0 → bg-error
|
||||||
|
// 2) 어떤 stage라도 pending > 10 → bg-warning
|
||||||
|
// 3) 그 외 (failed_count === 0) → bg-success
|
||||||
|
// 첫 fetch 전(null)에는 dim 회색 표시.
|
||||||
|
|
||||||
|
import {
|
||||||
|
dashboardSummary,
|
||||||
|
type DashboardSummary,
|
||||||
|
type PipelineStatus,
|
||||||
|
} from '$lib/stores/system';
|
||||||
|
|
||||||
|
type Tone = 'success' | 'error' | 'warning' | 'idle';
|
||||||
|
|
||||||
|
function pickTone(failedCount: number, pipeline: PipelineStatus[]): Tone {
|
||||||
|
if (failedCount > 0) return 'error';
|
||||||
|
const hasPendingBacklog = pipeline.some(
|
||||||
|
(p) => p.status === 'pending' && p.count > 10,
|
||||||
|
);
|
||||||
|
if (hasPendingBacklog) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLASS: Record<Tone, string> = {
|
||||||
|
success: 'bg-success',
|
||||||
|
error: 'bg-error',
|
||||||
|
warning: 'bg-warning',
|
||||||
|
idle: 'bg-default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_LABEL: Record<Tone, string> = {
|
||||||
|
success: '정상',
|
||||||
|
error: '실패 있음',
|
||||||
|
warning: '대기열 적체',
|
||||||
|
idle: '확인 중',
|
||||||
|
};
|
||||||
|
|
||||||
|
let tone: Tone = $derived(
|
||||||
|
$dashboardSummary
|
||||||
|
? pickTone($dashboardSummary.failed_count, $dashboardSummary.pipeline_status)
|
||||||
|
: 'idle',
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildStageRows(pipeline: PipelineStatus[]) {
|
||||||
|
// stage별로 status 카운트 합산 (extract/classify/embed/preview 등)
|
||||||
|
const grouped = new Map<string, Record<string, number>>();
|
||||||
|
for (const p of pipeline) {
|
||||||
|
const cur = grouped.get(p.stage) ?? {};
|
||||||
|
cur[p.status] = (cur[p.status] ?? 0) + p.count;
|
||||||
|
grouped.set(p.stage, cur);
|
||||||
|
}
|
||||||
|
return [...grouped.entries()].map(([stage, counts]) => ({
|
||||||
|
stage,
|
||||||
|
pending: counts.pending ?? 0,
|
||||||
|
processing: counts.processing ?? 0,
|
||||||
|
failed: counts.failed ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stageRows = $derived(
|
||||||
|
$dashboardSummary ? buildStageRows($dashboardSummary.pipeline_status) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildTooltip(
|
||||||
|
summary: DashboardSummary | null,
|
||||||
|
rows: ReturnType<typeof buildStageRows>,
|
||||||
|
currentTone: Tone,
|
||||||
|
): string {
|
||||||
|
if (!summary) return '시스템 상태 확인 중';
|
||||||
|
const head = `시스템: ${TONE_LABEL[currentTone]} (실패 ${summary.failed_count})`;
|
||||||
|
if (rows.length === 0) return head;
|
||||||
|
const lines = rows.map(
|
||||||
|
(r) => `${r.stage}: 대기 ${r.pending} · 처리 ${r.processing} · 실패 ${r.failed}`,
|
||||||
|
);
|
||||||
|
return [head, ...lines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooltipText = $derived(buildTooltip($dashboardSummary, stageRows, tone));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex h-2 w-2 rounded-full {TONE_CLASS[tone]}"
|
||||||
|
role="img"
|
||||||
|
aria-label={TONE_LABEL[tone]}
|
||||||
|
title={tooltipText}
|
||||||
|
></span>
|
||||||
79
frontend/src/lib/stores/system.ts
Normal file
79
frontend/src/lib/stores/system.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// 시스템 상태 store — Phase B에서 신규.
|
||||||
|
// /dashboard/ API 응답을 60초 주기로 폴링하고, 첫 subscribe 시 자동 시작한다.
|
||||||
|
// SystemStatusDot(B)과 dashboard(C)가 같은 fetch를 공유해 중복 호출을 방지.
|
||||||
|
//
|
||||||
|
// API 응답 shape: app/api/dashboard.py DashboardResponse 참조
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export interface DomainCount {
|
||||||
|
domain: string | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentDocument {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
file_format: string;
|
||||||
|
ai_domain: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineStatus {
|
||||||
|
stage: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
today_added: number;
|
||||||
|
today_by_domain: DomainCount[];
|
||||||
|
inbox_count: number;
|
||||||
|
law_alerts: number;
|
||||||
|
recent_documents: RecentDocument[];
|
||||||
|
pipeline_status: PipelineStatus[];
|
||||||
|
failed_count: number;
|
||||||
|
total_documents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let subscriberCount = 0;
|
||||||
|
let inFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const internal = writable<DashboardSummary | null>(null, (_set) => {
|
||||||
|
// svelte writable의 두 번째 인자는 첫 구독 시 호출되는 start fn,
|
||||||
|
// 반환값은 마지막 unsubscribe 시 호출되는 stop fn.
|
||||||
|
subscriberCount += 1;
|
||||||
|
if (subscriberCount === 1) {
|
||||||
|
void refresh();
|
||||||
|
pollHandle = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
subscriberCount -= 1;
|
||||||
|
if (subscriberCount === 0 && pollHandle) {
|
||||||
|
clearInterval(pollHandle);
|
||||||
|
pollHandle = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardSummary = { subscribe: internal.subscribe };
|
||||||
|
|
||||||
|
export async function refresh(): Promise<void> {
|
||||||
|
// 동시 fetch 합치기 — 폴링 + 수동 새로고침이 겹쳐도 1회만
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await api<DashboardSummary>('/dashboard/');
|
||||||
|
internal.set(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('대시보드 폴링 실패:', err);
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
@@ -3,9 +3,15 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { Menu } from 'lucide-svelte';
|
||||||
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
||||||
import { toasts, removeToast } from '$lib/stores/toast';
|
import { toasts, removeToast } from '$lib/stores/toast';
|
||||||
|
import { ui } from '$lib/stores/uiState.svelte';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
|
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/setup', '/__styleguide'];
|
const PUBLIC_PATHS = ['/login', '/setup', '/__styleguide'];
|
||||||
@@ -20,26 +26,14 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let authChecked = $state(false);
|
let authChecked = $state(false);
|
||||||
let sidebarOpen = $state(false);
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// localStorage에서 사이드바 상태 복원
|
|
||||||
const saved = localStorage.getItem('sidebarOpen');
|
|
||||||
if (saved === 'true') sidebarOpen = true;
|
|
||||||
|
|
||||||
if (!$isAuthenticated) {
|
if (!$isAuthenticated) {
|
||||||
await tryRefresh();
|
await tryRefresh();
|
||||||
}
|
}
|
||||||
authChecked = true;
|
authChecked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사이드바 상태 저장
|
|
||||||
$effect(() => {
|
|
||||||
if (browser) {
|
|
||||||
localStorage.setItem('sidebarOpen', String(sidebarOpen));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (browser && authChecked && !$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
|
if (browser && authChecked && !$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
|
||||||
goto('/login');
|
goto('/login');
|
||||||
@@ -53,8 +47,9 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.querySelector('[data-search-input]')?.focus();
|
document.querySelector('[data-search-input]')?.focus();
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape' && sidebarOpen) {
|
if (e.key === 'Escape') {
|
||||||
sidebarOpen = false;
|
// 5대 원칙 #2 — 글로벌 Esc는 uiState에 위임 (modal stack → drawer 우선순위 자동 처리)
|
||||||
|
ui.handleEscape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -72,46 +67,32 @@
|
|||||||
<nav class="flex items-center justify-between px-4 py-2 border-b border-default bg-surface shrink-0">
|
<nav class="flex items-center justify-between px-4 py-2 border-b border-default bg-surface shrink-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if !$page.url.pathname.startsWith('/news')}
|
{#if !$page.url.pathname.startsWith('/news')}
|
||||||
<button
|
<IconButton
|
||||||
onclick={() => sidebarOpen = !sidebarOpen}
|
icon={Menu}
|
||||||
class="p-1.5 rounded-md hover:bg-default text-dim"
|
size="sm"
|
||||||
aria-label="사이드바 토글"
|
aria-label="사이드바 토글"
|
||||||
>
|
onclick={() => ui.openDrawer('sidebar')}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
/>
|
||||||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<a href="/" class="text-sm font-semibold hover:text-accent">PKM</a>
|
<a href="/" class="text-sm font-semibold hover:text-accent">PKM</a>
|
||||||
<span class="text-dim text-xs">/</span>
|
<span class="text-dim text-xs">/</span>
|
||||||
<a href="/documents" class="text-xs hover:text-accent">문서</a>
|
<a href="/documents" class="text-xs hover:text-accent">문서</a>
|
||||||
|
<SystemStatusDot />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-1">
|
||||||
<a href="/news" class="text-xs text-dim hover:text-text">뉴스</a>
|
<Button variant="ghost" size="sm" href="/news">뉴스</Button>
|
||||||
<a href="/inbox" class="text-xs text-dim hover:text-text">Inbox</a>
|
<Button variant="ghost" size="sm" href="/inbox">Inbox</Button>
|
||||||
<a href="/settings" class="text-xs text-dim hover:text-text">설정</a>
|
<Button variant="ghost" size="sm" href="/settings">설정</Button>
|
||||||
<button
|
<Button variant="ghost" size="sm" onclick={() => logout()}>로그아웃</Button>
|
||||||
onclick={() => logout()}
|
|
||||||
class="text-xs text-dim hover:text-text"
|
|
||||||
>로그아웃</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 메인 -->
|
<!-- 메인 -->
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<!-- 사이드바 오버레이 (모든 화면에서 동일) -->
|
<!-- 사이드바 드로어 (모든 화면에서 동일, uiState 단일 slot 관리) -->
|
||||||
{#if sidebarOpen}
|
<Drawer id="sidebar" side="left" width="sidebar" aria-label="사이드바">
|
||||||
<div class="fixed inset-0 z-40">
|
<Sidebar />
|
||||||
<button
|
</Drawer>
|
||||||
onclick={() => sidebarOpen = false}
|
|
||||||
class="absolute inset-0 bg-black/40"
|
|
||||||
aria-label="사이드바 닫기"
|
|
||||||
></button>
|
|
||||||
<div class="absolute left-0 top-0 bottom-0 z-50" style="width: var(--sidebar-w)">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- 콘텐츠 -->
|
<!-- 콘텐츠 -->
|
||||||
<main class="h-full overflow-hidden">
|
<main class="h-full overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user