- +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"
119 lines
4.2 KiB
Svelte
119 lines
4.2 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
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'];
|
|
const NO_CHROME_PATHS = ['/login', '/setup', '/__styleguide'];
|
|
|
|
// toast 의미 토큰 매핑 (A-8 B3)
|
|
const TOAST_CLASS = {
|
|
success: 'bg-success/10 text-success border-success/30',
|
|
error: 'bg-error/10 text-error border-error/30',
|
|
warning: 'bg-warning/10 text-warning border-warning/30',
|
|
info: 'bg-accent/10 text-accent border-accent/30',
|
|
};
|
|
|
|
let authChecked = $state(false);
|
|
|
|
onMount(async () => {
|
|
if (!$isAuthenticated) {
|
|
await tryRefresh();
|
|
}
|
|
authChecked = true;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (browser && authChecked && !$isAuthenticated && !PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))) {
|
|
goto('/login');
|
|
}
|
|
});
|
|
|
|
let showChrome = $derived($isAuthenticated && !NO_CHROME_PATHS.some(p => $page.url.pathname.startsWith(p)));
|
|
|
|
function handleKeydown(e) {
|
|
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
|
|
e.preventDefault();
|
|
document.querySelector('[data-search-input]')?.focus();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
// 5대 원칙 #2 — 글로벌 Esc는 uiState에 위임 (modal stack → drawer 우선순위 자동 처리)
|
|
ui.handleEscape();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:keydown={handleKeydown} />
|
|
|
|
{#if !authChecked}
|
|
<div class="min-h-screen flex items-center justify-center">
|
|
<p class="text-dim">로딩 중...</p>
|
|
</div>
|
|
{:else if $isAuthenticated || PUBLIC_PATHS.some(p => $page.url.pathname.startsWith(p))}
|
|
{#if showChrome}
|
|
<div class="h-screen flex flex-col">
|
|
<!-- 상단 nav -->
|
|
<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')}
|
|
<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-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">
|
|
<!-- 사이드바 드로어 (모든 화면에서 동일, uiState 단일 slot 관리) -->
|
|
<Drawer id="sidebar" side="left" width="sidebar" aria-label="사이드바">
|
|
<Sidebar />
|
|
</Drawer>
|
|
|
|
<!-- 콘텐츠 -->
|
|
<main class="h-full overflow-hidden">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<slot />
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Toast -->
|
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm" role="status" aria-live="polite">
|
|
{#each $toasts as toast (toast.id)}
|
|
<button
|
|
class="px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 cursor-pointer text-left border {TOAST_CLASS[toast.type] || TOAST_CLASS.info}"
|
|
onclick={() => removeToast(toast.id)}
|
|
>
|
|
{toast.message}
|
|
</button>
|
|
{/each}
|
|
</div>
|