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:
Hyungi Ahn
2026-04-07 13:52:24 +09:00
parent a4eb71d368
commit 0c63c0b6ab
4 changed files with 214 additions and 45 deletions

View File

@@ -66,6 +66,24 @@
});
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>
<aside class="h-full flex flex-col bg-sidebar border-r border-default overflow-y-auto">
@@ -91,7 +109,7 @@
</div>
<!-- 트리 -->
<nav class="flex-1 px-2 py-2">
<nav class="flex-1 px-2 py-2" onkeydown={handleTreeKeydown}>
{#if loading}
{#each Array(5) as _}
<div class="h-8 bg-surface rounded-md animate-pulse mx-1 mb-1"></div>
@@ -123,6 +141,8 @@
<button
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
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-text'}"
>

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

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