feat(ui): Phase D.1 — 3-panel layout + DocumentMetaRail + useMedia

- 가로 flex 최상위 + 가운데 flex-1 (기존 list/viewer 세로 split 그대로 보존)
- xl+ (≥1280px): 우측 320px persistent rail, 접기 시 40px sliver.
  localStorage.metaRailOpen 으로 상태 유지.
- < xl : 기존 수동 drawer 제거하고 ui/Drawer primitive + uiState 사용.
- 리사이즈 시 xl+ 진입하면 drawer 자동 close (rail로 승계).
- handleKeydown → ui.handleEscape() 로 중앙화.
- ℹ 버튼 token 기반 재작성 (isXl 분기로 rail/drawer 토글).
- PreviewPanel.svelte 한 글자도 수정 없음 (Phase E 영역).

신규:
- frontend/src/lib/composables/useMedia.svelte.ts — matchMedia runes 컴포저블
- frontend/src/lib/components/DocumentMetaRail.svelte — PreviewPanel wrapper

검증:
- npm run build 통과
- npm run lint:tokens 241 → 236 (신규 코드 0 위반, 레거시 drawer/ℹ 버튼
  제거로 5건 organically 감소)
- PreviewPanel diff 0줄

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:18:06 +09:00
parent b3124928a6
commit ffac4975b9
3 changed files with 162 additions and 24 deletions

View File

@@ -3,13 +3,15 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Info } from 'lucide-svelte';
import { List, LayoutGrid } from 'lucide-svelte';
import { Info, List, LayoutGrid, ChevronLeft } from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import DocumentTable from '$lib/components/DocumentTable.svelte';
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentMetaRail from '$lib/components/DocumentMetaRail.svelte';
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
import { ui } from '$lib/stores/uiState.svelte';
import { useIsXl } from '$lib/composables/useMedia.svelte';
// 뷰 모드 (localStorage 기억)
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
@@ -25,7 +27,47 @@
let searchMode = $state('hybrid');
let searchResults = $state(null);
let selectedDoc = $state(null);
let infoPanelOpen = $state(false);
// D.1: 3-패널 레이아웃 상태.
// - xl+ : inline rail 사용 (metaRailOpen, localStorage persistent)
// - < xl: Drawer 프리미티브 사용 (ui.openDrawer('meta'))
const isXl = useIsXl();
let metaRailOpen = $state(
typeof localStorage !== 'undefined'
? localStorage.getItem('metaRailOpen') !== 'false'
: true
);
function setMetaRailOpen(v) {
metaRailOpen = v;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('metaRailOpen', String(v));
}
}
function toggleInfoPanel() {
if (!selectedDoc) return;
if (isXl.current) {
setMetaRailOpen(!metaRailOpen);
} else {
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
else ui.openDrawer('meta');
}
}
function handleDocDelete() {
selectedDoc = null;
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
loadDocuments();
}
// xl+ 진입 시 열려있던 drawer는 자동으로 닫는다 (rail로 승계).
$effect(() => {
if (isXl.current && ui.isDrawerOpen('meta')) {
ui.closeDrawer();
}
});
// URL params → filter
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
@@ -46,7 +88,7 @@
searchQuery = urlQ;
searchMode = urlMode;
selectedDoc = null;
infoPanelOpen = false;
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
if (urlQ) {
doSearch(urlQ, urlMode);
@@ -139,8 +181,8 @@
}
function handleKeydown(e) {
if (e.key === 'Escape' && infoPanelOpen) {
infoPanelOpen = false;
if (e.key === 'Escape') {
ui.handleEscape(); // drawer/modal stack 우선순위로 중앙 처리
}
}
@@ -151,7 +193,9 @@
<svelte:window on:keydown={handleKeydown} />
<div class="flex flex-col h-full">
<div class="flex h-full">
<!-- 가운데: 기존 list + viewer 세로 split (변경 없음) -->
<div class="flex-1 flex flex-col min-h-0">
<!-- 상단 영역 -->
<div class="{selectedDoc ? 'h-[30%] shrink-0 border-b border-[var(--border)]' : 'flex-1'} flex flex-col min-h-0">
<!-- 업로드 드롭존 -->
@@ -190,11 +234,15 @@
{/if}
</button>
{#if selectedDoc}
{@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
<button
onclick={() => infoPanelOpen = !infoPanelOpen}
class="p-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--accent)] text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors
{infoPanelOpen ? 'bg-[var(--accent)]/10 border-[var(--accent)] text-[var(--accent)]' : ''}"
type="button"
onclick={toggleInfoPanel}
class="p-1.5 rounded-lg border transition-colors {isPanelActive
? 'border-accent text-accent bg-accent/10'
: 'border-default text-dim hover:text-accent hover:border-accent'}"
aria-label="문서 정보"
aria-pressed={isPanelActive}
title="문서 정보"
>
<Info size={16} />
@@ -288,18 +336,43 @@
<DocumentViewer doc={selectedDoc} />
</div>
{/if}
</div>
<!-- /가운데 컬럼 -->
<!-- 우측: xl+ persistent rail (hidden xl:flex) -->
{#if selectedDoc}
<aside
class="hidden xl:flex shrink-0 border-l border-default {metaRailOpen ? 'w-rail' : 'w-10'}"
aria-label="문서 정보 rail"
>
{#if metaRailOpen}
<DocumentMetaRail
doc={selectedDoc}
onclose={() => setMetaRailOpen(false)}
ondelete={handleDocDelete}
/>
{:else}
<button
type="button"
onclick={() => setMetaRailOpen(true)}
class="w-10 h-full flex items-center justify-center text-dim hover:text-text hover:bg-surface-hover transition-colors"
aria-label="정보 패널 펼치기"
title="정보 패널 펼치기"
>
<ChevronLeft size={16} />
</button>
{/if}
</aside>
{/if}
</div>
<!-- 정보 패널: 우측 전체 높이 drawer -->
{#if infoPanelOpen && selectedDoc}
<div class="fixed inset-0 z-40">
<button
onclick={() => infoPanelOpen = false}
class="absolute inset-0 bg-black/40"
aria-label="정보 패널 닫기"
></button>
<div class="absolute right-0 top-0 bottom-0 z-50 w-[320px] shadow-xl">
<PreviewPanel doc={selectedDoc} onclose={() => infoPanelOpen = false} ondelete={() => { selectedDoc = null; infoPanelOpen = false; loadDocuments(); }} />
</div>
</div>
{/if}
<!-- < xl 폴백: Drawer primitive (우측 슬라이드) -->
<Drawer id="meta" side="right" width="rail" aria-label="문서 정보">
{#if selectedDoc}
<DocumentMetaRail
doc={selectedDoc}
onclose={() => ui.closeDrawer()}
ondelete={handleDocDelete}
/>
{/if}
</Drawer>