From ffac4975b980963cbba173bfd0d151ff0f0fae24 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 8 Apr 2026 12:18:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=20D.1=20=E2=80=94=203-panel?= =?UTF-8?q?=20layout=20+=20DocumentMetaRail=20+=20useMedia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가로 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) --- .../lib/components/DocumentMetaRail.svelte | 27 ++++ .../src/lib/composables/useMedia.svelte.ts | 38 ++++++ frontend/src/routes/documents/+page.svelte | 121 ++++++++++++++---- 3 files changed, 162 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/components/DocumentMetaRail.svelte create mode 100644 frontend/src/lib/composables/useMedia.svelte.ts diff --git a/frontend/src/lib/components/DocumentMetaRail.svelte b/frontend/src/lib/components/DocumentMetaRail.svelte new file mode 100644 index 0000000..f387de7 --- /dev/null +++ b/frontend/src/lib/components/DocumentMetaRail.svelte @@ -0,0 +1,27 @@ + + +
+ +
diff --git a/frontend/src/lib/composables/useMedia.svelte.ts b/frontend/src/lib/composables/useMedia.svelte.ts new file mode 100644 index 0000000..ecb2d1a --- /dev/null +++ b/frontend/src/lib/composables/useMedia.svelte.ts @@ -0,0 +1,38 @@ +// matchMedia 기반 runes 반응형 flag. 컴포넌트 script 최상위에서만 호출해야 +// $effect가 owner를 찾는다. SSR에서는 초기값 false로 가정하고, 클라이언트 +// mount 후 실제 값으로 업데이트. +// +// Phase D.1 신규 — documents/+page.svelte 에서 xl 브레이크포인트 기반으로 +// 메타 rail(persistent) 과 Drawer(폴백) 중 어느 쪽을 쓸지 분기. +// +// 사용: +// import { useIsXl } from '$lib/composables/useMedia.svelte'; +// const isXl = useIsXl(); +// {#if isXl.current} ... {/if} +// +// Tailwind v4 xl breakpoint = 1280px (app.css @theme 기본값과 일치). + +export interface MediaFlag { + readonly current: boolean; +} + +export function useMedia(query: string): MediaFlag { + let matches = $state(false); + + $effect(() => { + if (typeof window === 'undefined') return; + const mql = window.matchMedia(query); + matches = mql.matches; + const handler = (e: MediaQueryListEvent) => (matches = e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }); + + return { + get current() { + return matches; + }, + }; +} + +export const useIsXl = (): MediaFlag => useMedia('(min-width: 1280px)'); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index ee0e7f2..7dcbbf0 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -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 @@ -
+
+ +
@@ -190,11 +234,15 @@ {/if} {#if selectedDoc} + {@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
{/if} +
+ + + + {#if selectedDoc} + + {/if}
- -{#if infoPanelOpen && selectedDoc} -
- -
- infoPanelOpen = false} ondelete={() => { selectedDoc = null; infoPanelOpen = false; loadDocuments(); }} /> -
-
-{/if} + + + {#if selectedDoc} + ui.closeDrawer()} + ondelete={handleDocDelete} + /> + {/if} +