From 8f312f50a74a8b1c42a0e87a0472e1449847c3b9 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 8 Apr 2026 12:27:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=20D.2=20=E2=80=94=20filter=20?= =?UTF-8?q?chips=20+=20URL=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색바 아래 새 필터 칩 row: domain/tag/format/source 활성 필터를 인라인 칩으로 렌더, 각 칩에 X 버튼으로 제거. - `+ 태그` popover: 현재 결과의 상위 20개 태그 클라이언트 집계 (items.flatMap(d => d.ai_tags).counts + sort) → 선택 시 ?tag=... - `+ 형식` popover: FORMATS 화이트리스트 (pdf/hwp/hwpx/md/docx/xlsx/png/jpg) → 선택 시 ?format=... - 바깥 클릭으로 popover 자동 close ($effect + document listener) - filterFormat $derived + loadDocuments params 확장 + hasActiveFilters 확장 - 결과 헤더는 카운트만 남기고 필터 표시/초기화는 칩 row로 이전 (중복 제거) - addFilter/removeFilter 헬퍼로 URL 라운드트립 관리 (domain 제거 시 sub_group 함께) - 백엔드 변경 없음 (GET /documents/가 이미 tag/format 지원) 검증: - npm run build 통과 - npm run lint:tokens 236 → 231 (신규 코드 0 위반, 결과 헤더 리팩토링으로 5건 organically 감소) - popover 키보드 a11y (role=listbox/option, aria-expanded, aria-selected) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/routes/documents/+page.svelte | 224 +++++++++++++++++++-- 1 file changed, 204 insertions(+), 20 deletions(-) diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 7dcbbf0..982ebd6 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { Info, List, LayoutGrid, ChevronLeft } from 'lucide-svelte'; + import { Info, List, LayoutGrid, ChevronLeft, X, Plus } from 'lucide-svelte'; import DocumentCard from '$lib/components/DocumentCard.svelte'; import DocumentTable from '$lib/components/DocumentTable.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; @@ -13,6 +13,10 @@ import { ui } from '$lib/stores/uiState.svelte'; import { useIsXl } from '$lib/composables/useMedia.svelte'; + // D.2: 필터 칩에서 사용할 format 화이트리스트. + // 백엔드 `GET /documents/?format=...` 파라미터가 이미 받음. + const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg']; + // 뷰 모드 (localStorage 기억) let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card'); function toggleViewMode() { @@ -75,6 +79,11 @@ let filterSubGroup = $derived($page.url.searchParams.get('sub_group') || ''); let filterTag = $derived($page.url.searchParams.get('tag') || ''); let filterSource = $derived($page.url.searchParams.get('source') || ''); + let filterFormat = $derived($page.url.searchParams.get('format') || ''); + + // D.2: 필터 칩 popover 상태 + let tagPopoverOpen = $state(false); + let formatPopoverOpen = $state(false); $effect(() => { const _p = currentPage; @@ -82,6 +91,7 @@ const _s = filterSubGroup; const _t = filterTag; const _src = filterSource; + const _f = filterFormat; const urlQ = $page.url.searchParams.get('q') || ''; const urlMode = $page.url.searchParams.get('mode') || 'hybrid'; @@ -108,6 +118,7 @@ if (filterSubGroup) params.set('sub_group', filterSubGroup); if (filterTag) params.set('tag', filterTag); if (filterSource) params.set('source', filterSource); + if (filterFormat) params.set('format', filterFormat); const data = await api(`/documents/?${params}`); documents = data.items; @@ -176,6 +187,26 @@ searchQuery = ''; } + // D.2: 필터 add/remove 헬퍼 — URL 라운드트립으로 필터 상태 관리. + function addFilter(key, value) { + const params = new URLSearchParams($page.url.searchParams); + params.set(key, value); + params.delete('page'); + const qs = params.toString(); + goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); + tagPopoverOpen = false; + formatPopoverOpen = false; + } + + function removeFilter(key) { + const params = new URLSearchParams($page.url.searchParams); + params.delete(key); + // domain 제거 시 sub_group도 함께 제거 (의존성) + if (key === 'domain') params.delete('sub_group'); + const qs = params.toString(); + goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); + } + function selectDoc(doc) { selectedDoc = selectedDoc?.id === doc.id ? null : doc; } @@ -188,7 +219,37 @@ let totalPages = $derived(Math.ceil(total / 20)); let items = $derived(searchResults || documents); - let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!searchQuery); + let hasActiveFilters = $derived( + !!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!filterFormat || !!searchQuery + ); + + // D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음). + let topTags = $derived.by(() => { + const counts = new Map(); + for (const d of items) { + for (const t of d.ai_tags || []) { + counts.set(t, (counts.get(t) || 0) + 1); + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + }); + + // 바깥 클릭으로 popover 닫기 + $effect(() => { + if (!tagPopoverOpen && !formatPopoverOpen) return; + function onDocClick(e) { + const target = e.target; + if (!(target instanceof Element)) return; + if (!target.closest('[data-popover-root]')) { + tagPopoverOpen = false; + formatPopoverOpen = false; + } + } + document.addEventListener('click', onDocClick); + return () => document.removeEventListener('click', onDocClick); + }); @@ -250,27 +311,150 @@ {/if} + +
+ {#if filterDomain} + + {filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''} + + + {/if} + + {#if filterTag} + + #{filterTag} + + + {/if} + + {#if filterFormat} + + {filterFormat} + + + {/if} + + {#if filterSource} + + {filterSource} + + + {/if} + + +
+ + {#if tagPopoverOpen} +
+ {#if topTags.length === 0} +

현재 결과에 태그 없음

+ {:else} + {#each topTags as [tag, count]} + + {/each} + {/if} +
+ {/if} +
+ + +
+ + {#if formatPopoverOpen} +
+ {#each FORMATS as fmt} + + {/each} +
+ {/if} +
+ + {#if hasActiveFilters} + + {/if} +
+
- + {#if !loading} -
-
- {total}건 - {#if filterDomain} - - {filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''} - - {/if} -
- {#if hasActiveFilters} - - {/if} +
+ {total}건
{/if}