feat(ui): 셸 재구성 — nav 4그룹·데스크탑 상시 사이드바·모바일 하단탭바 (F2)

+layout.svelte: 상단 nav 11개 flat → 4그룹(홈·문서▾·뉴스▾·질문, 드롭다운) +
브랜드(DS)·받은편지함·⋮(설정/로그아웃). 데스크탑(lg+)=상시 좌측 사이드바,
모바일(<lg)=하단 탭바(문서·뉴스·질문·메모·더보기) + 사이드바 드로어.
세이지 토큰 Tailwind. /news=풀스크린(상시 사이드바 없음). frontend docker build PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-04 05:02:11 +00:00
parent e968236796
commit cc8bdee6c1
+97 -69
View File
@@ -3,7 +3,7 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Menu, EllipsisVertical } from 'lucide-svelte';
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox } from 'lucide-svelte';
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/toast';
import { refresh as refreshPublicConfig } from '$lib/stores/config';
@@ -11,13 +11,14 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
import QuickMemoButton from '$lib/components/QuickMemoButton.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'];
// /news = 풀스크린 브리핑 → 데스크탑 상시 사이드바 없음
const NO_SIDEBAR_PATHS = ['/news'];
// toast 의미 토큰 매핑 (A-8 B3)
const TOAST_CLASS = {
@@ -28,18 +29,21 @@
};
let authChecked = $state(false);
let menuOpen = $state(false);
let menuOpen = $state(false); // ⋮ 설정 메뉴
let navMenu = $state(''); // '' | 'docs' | 'news' — 상단 드롭다운
function isActive(path) {
return $page.url.pathname.startsWith(path);
}
// 그룹 active
let docsActive = $derived(['/documents', '/library', '/audio', '/video'].some(isActive));
let newsActive = $derived(['/news', '/digest'].some(isActive));
onMount(async () => {
if (!$isAuthenticated) {
await tryRefresh();
}
authChecked = true;
// 공개 설정 prewarm (인증 상태와 독립적). 실패 시 fallback 유지.
void refreshPublicConfig();
});
@@ -50,6 +54,7 @@
});
let showChrome = $derived($isAuthenticated && !NO_CHROME_PATHS.some(p => $page.url.pathname.startsWith(p)));
let showSidebar = $derived(showChrome && !NO_SIDEBAR_PATHS.some(p => $page.url.pathname.startsWith(p)));
function handleKeydown(e) {
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
@@ -57,10 +62,12 @@
document.querySelector('[data-search-input]')?.focus();
}
if (e.key === 'Escape') {
// 5대 원칙 #2 — 글로벌 Esc는 uiState에 위임 (modal stack → drawer 우선순위 자동 처리)
ui.handleEscape();
navMenu = '';
menuOpen = false;
}
}
function nav(path) { navMenu = ''; goto(path); }
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -73,81 +80,102 @@
{#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 {isActive('/documents') ? 'text-accent' : ''}">문서</a>
<span class="text-faint text-xs">·</span>
<a href="/library" class="text-xs hover:text-accent {isActive('/library') ? 'text-accent' : ''}">자료실</a>
<span class="text-faint text-xs">·</span>
<a href="/audio" class="text-xs hover:text-accent {isActive('/audio') ? 'text-accent' : ''}">오디오</a>
<span class="text-faint text-xs">·</span>
<a href="/video" class="text-xs hover:text-accent {isActive('/video') ? 'text-accent' : ''}">비디오</a>
<SystemStatusDot />
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" href="/ask" class={isActive('/ask') ? 'text-accent' : ''}>질문</Button>
<Button variant="ghost" size="sm" href="/memos" class={isActive('/memos') ? 'text-accent' : ''}>메모</Button>
<Button variant="ghost" size="sm" href="/study" class={isActive('/study') ? 'text-accent' : ''}>공부</Button>
<Button variant="ghost" size="sm" href="/news" class={isActive('/news') ? 'text-accent' : ''}>아침 브리핑</Button>
<Button variant="ghost" size="sm" href="/digest" class={isActive('/digest') ? 'text-accent' : ''}>다이제스트</Button>
<Button variant="ghost" size="sm" href="/inbox" class={isActive('/inbox') ? 'text-accent' : ''}>Inbox</Button>
<nav class="flex items-center gap-3 px-4 h-14 border-b border-default bg-sidebar shrink-0">
{#if showSidebar}
<div class="lg:hidden">
<IconButton icon={Menu} size="sm" aria-label="사이드바" onclick={() => ui.openDrawer('sidebar')} />
</div>
{/if}
<a href="/" class="flex items-center gap-2 shrink-0">
<span class="w-7 h-7 rounded-md bg-accent text-white grid place-items-center text-[10px] font-extrabold tracking-wide">DS</span>
<span class="font-extrabold text-[15px] tracking-tight hidden sm:inline">Document Server</span>
</a>
<!-- 데스크탑 4그룹 -->
<div class="hidden lg:flex items-center gap-1 ml-2">
<a href="/" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {$page.url.pathname === '/' ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}"></a>
<div class="relative">
<IconButton
icon={EllipsisVertical}
size="sm"
aria-label="메뉴"
aria-expanded={menuOpen}
aria-haspopup="menu"
onclick={() => (menuOpen = !menuOpen)}
/>
{#if menuOpen}
<button
onclick={() => (menuOpen = false)}
class="fixed inset-0 z-40"
aria-label="메뉴 닫기"
></button>
<div class="absolute right-0 top-full z-50 mt-1 min-w-[140px] bg-surface border border-default rounded-lg shadow-lg py-1" role="menu">
<a
href="/settings"
onclick={() => (menuOpen = false)}
class="block px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors"
role="menuitem"
>설정</a>
<button
onclick={() => { menuOpen = false; logout(); }}
class="w-full text-left px-4 py-2 text-sm text-error hover:bg-error/10 transition-colors"
role="menuitem"
>로그아웃</button>
<button onclick={() => (navMenu = navMenu === 'docs' ? '' : 'docs')} aria-haspopup="menu" aria-expanded={navMenu === 'docs'}
class="px-3 py-1.5 rounded-md text-sm font-semibold flex items-center gap-1 transition-colors {docsActive ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">
문서 <ChevronDown size={13} />
</button>
{#if navMenu === 'docs'}
<button onclick={() => (navMenu = '')} class="fixed inset-0 z-40" aria-label="닫기"></button>
<div class="absolute left-0 top-full z-50 mt-1 min-w-[160px] bg-surface border border-default rounded-lg shadow-lg py-1" role="menu">
<button onclick={() => nav('/documents')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">전체 문서</button>
<button onclick={() => nav('/library')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">자료실</button>
<button onclick={() => nav('/audio')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">오디오</button>
<button onclick={() => nav('/video')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">비디오</button>
</div>
{/if}
</div>
<div class="relative">
<button onclick={() => (navMenu = navMenu === 'news' ? '' : 'news')} aria-haspopup="menu" aria-expanded={navMenu === 'news'}
class="px-3 py-1.5 rounded-md text-sm font-semibold flex items-center gap-1 transition-colors {newsActive ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">
뉴스 <ChevronDown size={13} />
</button>
{#if navMenu === 'news'}
<button onclick={() => (navMenu = '')} class="fixed inset-0 z-40" aria-label="닫기"></button>
<div class="absolute left-0 top-full z-50 mt-1 min-w-[160px] bg-surface border border-default rounded-lg shadow-lg py-1" role="menu">
<button onclick={() => nav('/news')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">아침 브리핑</button>
<button onclick={() => nav('/digest')} class="w-full text-left px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">다이제스트</button>
</div>
{/if}
</div>
<a href="/ask" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/ask') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">질문</a>
<SystemStatusDot />
</div>
<div class="flex-1"></div>
<a href="/inbox" aria-label="받은편지함"
class="hidden lg:grid place-items-center w-8 h-8 rounded-md border border-default transition-colors {isActive('/inbox') ? 'text-accent border-accent' : 'text-dim hover:text-accent hover:border-accent'}">
<Inbox size={15} />
</a>
<div class="relative">
<IconButton icon={EllipsisVertical} size="sm" aria-label="메뉴" aria-expanded={menuOpen} aria-haspopup="menu" onclick={() => (menuOpen = !menuOpen)} />
{#if menuOpen}
<button onclick={() => (menuOpen = false)} class="fixed inset-0 z-40" aria-label="메뉴 닫기"></button>
<div class="absolute right-0 top-full z-50 mt-1 min-w-[140px] bg-surface border border-default rounded-lg shadow-lg py-1" role="menu">
<a href="/settings" onclick={() => (menuOpen = false)} class="block px-4 py-2 text-sm text-text hover:bg-surface-hover transition-colors" role="menuitem">설정</a>
<button onclick={() => { menuOpen = false; logout(); }} class="w-full text-left px-4 py-2 text-sm text-error hover:bg-error/10 transition-colors" role="menuitem">로그아웃</button>
</div>
{/if}
</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-auto">
<!-- 메인: 데스크탑 상시 사이드바 + 콘텐츠 -->
<div class="flex-1 min-h-0 flex">
{#if showSidebar}
<aside class="hidden lg:block w-sidebar shrink-0 overflow-hidden border-r border-default">
<Sidebar />
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-auto">
<slot />
</main>
</div>
<!-- 빠른 메모 FAB (모든 페이지) -->
<!-- 모바일 하단 탭바 -->
<nav class="lg:hidden shrink-0 flex border-t border-default bg-sidebar" aria-label="하단 탭">
<a href="/documents" aria-current={docsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {docsActive ? 'text-accent' : 'text-dim'}"><FileText size={18} strokeWidth={1.9} /> 문서</a>
<a href="/news" aria-current={newsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {newsActive ? 'text-accent' : 'text-dim'}"><Newspaper size={18} strokeWidth={1.9} /> 뉴스</a>
<a href="/ask" aria-current={isActive('/ask') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/ask') ? 'text-accent' : 'text-dim'}"><HelpCircle size={18} strokeWidth={1.9} /> 질문</a>
<a href="/memos" aria-current={isActive('/memos') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/memos') ? 'text-accent' : 'text-dim'}"><StickyNote size={18} strokeWidth={1.9} /> 메모</a>
<button onclick={() => ui.openDrawer('sidebar')} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold text-dim"><Menu size={18} strokeWidth={1.9} /> 더보기</button>
</nav>
<!-- 모바일 사이드바 드로어 (chrome 있는 모든 페이지) -->
<div class="lg:hidden">
<Drawer id="sidebar" side="left" width="sidebar" aria-label="사이드바">
<Sidebar />
</Drawer>
</div>
<!-- 빠른 메모 FAB -->
<QuickMemoButton />
</div>
{:else}