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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user