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:
27
frontend/src/lib/components/DocumentMetaRail.svelte
Normal file
27
frontend/src/lib/components/DocumentMetaRail.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Phase D.1 신규 — 얇은 wrapper.
|
||||||
|
// - PreviewPanel을 그대로 import해서 rail/drawer 양쪽 컨텍스트에서 재사용.
|
||||||
|
// - Phase E에서 editors/* 로 분할될 때 이 wrapper 자체는 유지되고
|
||||||
|
// 내부만 <NoteEditor/>, <TagsEditor/>... 조합으로 교체된다.
|
||||||
|
// - rail 모드(xl+ inline)와 drawer 모드(< xl)에서 똑같이 사용되며,
|
||||||
|
// onclose 콜백만 부모가 다르게 준다:
|
||||||
|
// rail → metaRailOpen = false + localStorage 저장
|
||||||
|
// drawer → ui.closeDrawer()
|
||||||
|
//
|
||||||
|
// PreviewPanel 은 자기 <aside> 내부에 bg-sidebar/border-l 스타일을 이미
|
||||||
|
// 갖고 있으므로, 여기서는 최소 <div> flex wrapper만 씌운다. border 중복
|
||||||
|
// 방지.
|
||||||
|
import PreviewPanel from './PreviewPanel.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
doc: unknown;
|
||||||
|
onclose: () => void;
|
||||||
|
ondelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { doc, onclose, ondelete = () => {} }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full w-full flex flex-col">
|
||||||
|
<PreviewPanel {doc} {onclose} {ondelete} />
|
||||||
|
</div>
|
||||||
38
frontend/src/lib/composables/useMedia.svelte.ts
Normal file
38
frontend/src/lib/composables/useMedia.svelte.ts
Normal file
@@ -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)');
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/toast';
|
import { addToast } from '$lib/stores/toast';
|
||||||
import { Info } from 'lucide-svelte';
|
import { Info, List, LayoutGrid, ChevronLeft } from 'lucide-svelte';
|
||||||
import { List, LayoutGrid } from 'lucide-svelte';
|
|
||||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||||
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
||||||
import PreviewPanel from '$lib/components/PreviewPanel.svelte';
|
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
|
import DocumentMetaRail from '$lib/components/DocumentMetaRail.svelte';
|
||||||
import UploadDropzone from '$lib/components/UploadDropzone.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 기억)
|
// 뷰 모드 (localStorage 기억)
|
||||||
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
|
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
|
||||||
@@ -25,7 +27,47 @@
|
|||||||
let searchMode = $state('hybrid');
|
let searchMode = $state('hybrid');
|
||||||
let searchResults = $state(null);
|
let searchResults = $state(null);
|
||||||
let selectedDoc = $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
|
// URL params → filter
|
||||||
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
|
let currentPage = $derived(parseInt($page.url.searchParams.get('page') || '1'));
|
||||||
@@ -46,7 +88,7 @@
|
|||||||
searchQuery = urlQ;
|
searchQuery = urlQ;
|
||||||
searchMode = urlMode;
|
searchMode = urlMode;
|
||||||
selectedDoc = null;
|
selectedDoc = null;
|
||||||
infoPanelOpen = false;
|
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
|
||||||
|
|
||||||
if (urlQ) {
|
if (urlQ) {
|
||||||
doSearch(urlQ, urlMode);
|
doSearch(urlQ, urlMode);
|
||||||
@@ -139,8 +181,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.key === 'Escape' && infoPanelOpen) {
|
if (e.key === 'Escape') {
|
||||||
infoPanelOpen = false;
|
ui.handleEscape(); // drawer/modal stack 우선순위로 중앙 처리
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +193,9 @@
|
|||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<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">
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if selectedDoc}
|
{#if selectedDoc}
|
||||||
|
{@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
|
||||||
<button
|
<button
|
||||||
onclick={() => infoPanelOpen = !infoPanelOpen}
|
type="button"
|
||||||
class="p-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--accent)] text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors
|
onclick={toggleInfoPanel}
|
||||||
{infoPanelOpen ? 'bg-[var(--accent)]/10 border-[var(--accent)] text-[var(--accent)]' : ''}"
|
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-label="문서 정보"
|
||||||
|
aria-pressed={isPanelActive}
|
||||||
title="문서 정보"
|
title="문서 정보"
|
||||||
>
|
>
|
||||||
<Info size={16} />
|
<Info size={16} />
|
||||||
@@ -288,18 +336,43 @@
|
|||||||
<DocumentViewer doc={selectedDoc} />
|
<DocumentViewer doc={selectedDoc} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<!-- 정보 패널: 우측 전체 높이 drawer -->
|
<!-- < xl 폴백: Drawer primitive (우측 슬라이드) -->
|
||||||
{#if infoPanelOpen && selectedDoc}
|
<Drawer id="meta" side="right" width="rail" aria-label="문서 정보">
|
||||||
<div class="fixed inset-0 z-40">
|
{#if selectedDoc}
|
||||||
<button
|
<DocumentMetaRail
|
||||||
onclick={() => infoPanelOpen = false}
|
doc={selectedDoc}
|
||||||
class="absolute inset-0 bg-black/40"
|
onclose={() => ui.closeDrawer()}
|
||||||
aria-label="정보 패널 닫기"
|
ondelete={handleDocDelete}
|
||||||
></button>
|
/>
|
||||||
<div class="absolute right-0 top-0 bottom-0 z-50 w-[320px] shadow-xl">
|
{/if}
|
||||||
<PreviewPanel doc={selectedDoc} onclose={() => infoPanelOpen = false} ondelete={() => { selectedDoc = null; infoPanelOpen = false; loadDocuments(); }} />
|
</Drawer>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user