feat(ui): Phase D.2 — filter chips + URL sync
- 검색바 아래 새 필터 칩 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
@@ -250,27 +311,150 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- D.2: 필터 칩 row -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 px-4 pb-2 shrink-0">
|
||||
{#if filterDomain}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
|
||||
{filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeFilter('domain')}
|
||||
class="hover:text-error -mr-0.5 ml-0.5"
|
||||
aria-label="도메인 필터 제거"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if filterTag}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default">
|
||||
#{filterTag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeFilter('tag')}
|
||||
class="hover:text-error -mr-0.5 ml-0.5"
|
||||
aria-label="태그 필터 제거"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if filterFormat}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default uppercase">
|
||||
{filterFormat}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeFilter('format')}
|
||||
class="hover:text-error -mr-0.5 ml-0.5"
|
||||
aria-label="형식 필터 제거"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if filterSource}
|
||||
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default">
|
||||
{filterSource}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeFilter('source')}
|
||||
class="hover:text-error -mr-0.5 ml-0.5"
|
||||
aria-label="소스 필터 제거"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- + 태그 popover -->
|
||||
<div class="relative" data-popover-root>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { tagPopoverOpen = !tagPopoverOpen; formatPopoverOpen = false; }}
|
||||
class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-dim border border-default border-dashed hover:text-text hover:border-accent transition-colors"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={tagPopoverOpen}
|
||||
>
|
||||
<Plus size={10} /> 태그
|
||||
</button>
|
||||
{#if tagPopoverOpen}
|
||||
<div
|
||||
class="absolute top-full left-0 mt-1 z-dropdown bg-surface border border-default rounded-md shadow-lg min-w-[180px] max-h-[280px] overflow-y-auto py-1"
|
||||
role="listbox"
|
||||
aria-label="태그 선택"
|
||||
>
|
||||
{#if topTags.length === 0}
|
||||
<p class="text-[10px] text-dim px-3 py-2">현재 결과에 태그 없음</p>
|
||||
{:else}
|
||||
{#each topTags as [tag, count]}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={filterTag === tag}
|
||||
onclick={() => addFilter('tag', tag)}
|
||||
class="flex items-center justify-between w-full text-left text-[11px] px-3 py-1 text-text hover:bg-surface-hover"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
<span class="text-dim ml-2 tabular-nums">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- + 형식 popover -->
|
||||
<div class="relative" data-popover-root>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { formatPopoverOpen = !formatPopoverOpen; tagPopoverOpen = false; }}
|
||||
class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-dim border border-default border-dashed hover:text-text hover:border-accent transition-colors"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={formatPopoverOpen}
|
||||
>
|
||||
<Plus size={10} /> 형식
|
||||
</button>
|
||||
{#if formatPopoverOpen}
|
||||
<div
|
||||
class="absolute top-full left-0 mt-1 z-dropdown bg-surface border border-default rounded-md shadow-lg min-w-[140px] py-1"
|
||||
role="listbox"
|
||||
aria-label="형식 선택"
|
||||
>
|
||||
{#each FORMATS as fmt}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={filterFormat === fmt}
|
||||
onclick={() => addFilter('format', fmt)}
|
||||
class="flex items-center w-full text-left text-[11px] px-3 py-1 text-text hover:bg-surface-hover uppercase"
|
||||
>
|
||||
{fmt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasActiveFilters}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAllFilters}
|
||||
class="text-[10px] text-dim hover:text-text px-2 py-0.5 ml-auto"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 스크롤 영역 (목록) -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<!-- 결과 헤더 -->
|
||||
<!-- 결과 헤더 — 카운트만. 필터는 위의 칩 row가 표시. -->
|
||||
{#if !loading}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-[var(--text-dim)]">{total}건</span>
|
||||
{#if filterDomain}
|
||||
<span class="text-[10px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">
|
||||
{filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasActiveFilters}
|
||||
<button
|
||||
onclick={clearAllFilters}
|
||||
class="text-[10px] text-[var(--text-dim)] hover:text-[var(--text)] px-1.5 py-0.5 rounded border border-[var(--border)]"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{/if}
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="text-[10px] text-dim">{total}건</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user