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

@@ -3,9 +3,15 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Menu } from 'lucide-svelte';
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/toast';
import { ui } from '$lib/stores/uiState.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';
const PUBLIC_PATHS = ['/login', '/setup', '/__styleguide'];
@@ -20,26 +26,14 @@
};
let authChecked = $state(false);
let sidebarOpen = $state(false);
onMount(async () => {
// localStorage에서 사이드바 상태 복원
const saved = localStorage.getItem('sidebarOpen');
if (saved === 'true') sidebarOpen = true;
if (!$isAuthenticated) {
await tryRefresh();
}
authChecked = true;
});
// 사이드바 상태 저장
$effect(() => {
if (browser) {
localStorage.setItem('sidebarOpen', String(sidebarOpen));
}
});
$effect(() => {
if (browser && authChecked && !$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
goto('/login');
@@ -53,8 +47,9 @@
e.preventDefault();
document.querySelector('[data-search-input]')?.focus();
}
if (e.key === 'Escape' && sidebarOpen) {
sidebarOpen = false;
if (e.key === 'Escape') {
// 5대 원칙 #2 — 글로벌 Esc는 uiState에 위임 (modal stack → drawer 우선순위 자동 처리)
ui.handleEscape();
}
}
</script>
@@ -72,46 +67,32 @@
<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">
{#if !$page.url.pathname.startsWith('/news')}
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="p-1.5 rounded-md hover:bg-default text-dim"
aria-label="사이드바 토글"
>
<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>
<IconButton
icon={Menu}
size="sm"
aria-label="사이드바 토글"
onclick={() => ui.openDrawer('sidebar')}
/>
{/if}
<a href="/" class="text-sm font-semibold hover:text-accent">PKM</a>
<span class="text-dim text-xs">/</span>
<a href="/documents" class="text-xs hover:text-accent">문서</a>
<SystemStatusDot />
</div>
<div class="flex items-center gap-3">
<a href="/news" class="text-xs text-dim hover:text-text">뉴스</a>
<a href="/inbox" class="text-xs text-dim hover:text-text">Inbox</a>
<a href="/settings" class="text-xs text-dim hover:text-text">설정</a>
<button
onclick={() => logout()}
class="text-xs text-dim hover:text-text"
>로그아웃</button>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" href="/news">뉴스</Button>
<Button variant="ghost" size="sm" href="/inbox">Inbox</Button>
<Button variant="ghost" size="sm" href="/settings">설정</Button>
<Button variant="ghost" size="sm" onclick={() => logout()}>로그아웃</Button>
</div>
</nav>
<!-- 메인 -->
<div class="flex-1 min-h-0 relative">
<!-- 사이드바 오버레이 (모든 화면에서 동일) -->
{#if sidebarOpen}
<div class="fixed inset-0 z-40">
<button
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}
<!-- 사이드바 드로어 (모든 화면에서 동일, uiState 단일 slot 관리) -->
<Drawer id="sidebar" side="left" width="sidebar" aria-label="사이드바">
<Sidebar />
</Drawer>
<!-- 콘텐츠 -->
<main class="h-full overflow-hidden">