feat(library): Phase 2B facet 필터 패널 + 문서 목록 연동

자료실 좌측에 회사/주제/연도/문서유형 facet pill 패널 추가.
single-select 토글, count 표시, 교차 필터 (자기 축 제외).
URL searchParams 기반 상태 관리 (뒤로가기/새로고침 유지).
loadDocs에 facet 파라미터 전달, loadFacetCounts 분리 (page/sort 제외).
count 0은 dim+disabled, 초기화 버튼 포함.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-15 10:25:39 +09:00
parent ba19c6fb79
commit 776734c897
+110 -2
View File
@@ -52,6 +52,19 @@
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
const DEFAULT_LIBRARY_PATH = '미분류';
const MAX_DEPTH = 5;
const FACET_LABELS = { company: '회사', topic: '주제', year: '연도', doctype: '문서유형' };
const FACET_KEYS = ['facet_company', 'facet_topic', 'facet_year', 'facet_doctype'];
// ─── Facet 상태 ───
let facetCounts = $state({ company: [], topic: [], year: [], doctype: [] });
let facetLoading = $state(false);
let activeFacetCompany = $derived($page.url.searchParams.get('facet_company'));
let activeFacetTopic = $derived($page.url.searchParams.get('facet_topic'));
let activeFacetYear = $derived($page.url.searchParams.get('facet_year'));
let activeFacetDoctype = $derived($page.url.searchParams.get('facet_doctype'));
let hasAnyFacet = $derived(activeFacetCompany || activeFacetTopic || activeFacetYear || activeFacetDoctype);
// ─── 카테고리 CRUD 상태 ───
@@ -90,6 +103,10 @@
if (activePath) params.set('path', activePath);
if (activeSort) params.set('sort', activeSort);
if (activeQ) params.set('q', activeQ);
if (activeFacetCompany) params.set('facet_company', activeFacetCompany);
if (activeFacetTopic) params.set('facet_topic', activeFacetTopic);
if (activeFacetYear) params.set('facet_year', activeFacetYear);
if (activeFacetDoctype) params.set('facet_doctype', activeFacetDoctype);
params.set('page', String(activePage));
params.set('page_size', '20');
const result = await api(`/documents/library?${params}`);
@@ -102,17 +119,42 @@
}
}
async function loadFacetCounts() {
facetLoading = true;
try {
const params = new URLSearchParams();
if (activePath) params.set('library_path', activePath);
if (activeQ) params.set('q', activeQ);
if (activeFacetCompany) params.set('facet_company', activeFacetCompany);
if (activeFacetTopic) params.set('facet_topic', activeFacetTopic);
if (activeFacetYear) params.set('facet_year', activeFacetYear);
if (activeFacetDoctype) params.set('facet_doctype', activeFacetDoctype);
facetCounts = await api(`/library/facet-counts?${params}`);
} catch {
/* facet 실패해도 문서 목록은 정상 */
} finally {
facetLoading = false;
}
}
onMount(() => {
loadTree();
});
// URL 파라미터 변경 시 문서 목록 재로드
// 문서 목록: 모든 URL 파라미터 변경 시 재로드
$effect(() => {
// eslint-disable-next-line no-unused-expressions
activePath, activeSort, activeQ, activePage;
activePath, activeSort, activeQ, activePage, activeFacetCompany, activeFacetTopic, activeFacetYear, activeFacetDoctype;
loadDocs();
});
// facet counts: path/q/facet 변경 시만 재집계 (page/sort 제외)
$effect(() => {
// eslint-disable-next-line no-unused-expressions
activePath, activeQ, activeFacetCompany, activeFacetTopic, activeFacetYear, activeFacetDoctype;
loadFacetCounts();
});
// 선택된 경로의 부모 자동 펼치기
$effect(() => {
if (activePath) {
@@ -159,6 +201,25 @@
goto(`/library?${params}`, { noScroll: true });
}
function toggleFacet(key, value) {
const params = new URLSearchParams($page.url.searchParams);
const current = params.get(key);
if (current === String(value)) {
params.delete(key);
} else {
params.set(key, String(value));
}
params.delete('page');
goto(`/library?${params}`, { noScroll: true });
}
function clearAllFacets() {
const params = new URLSearchParams($page.url.searchParams);
for (const k of FACET_KEYS) params.delete(k);
params.delete('page');
goto(`/library?${params}`, { noScroll: true });
}
function toggleExpand(path) {
expanded[path] = !expanded[path];
}
@@ -480,6 +541,53 @@
</nav>
{/if}
</div>
<!-- Facet 필터 패널 -->
{#if facetCounts.company.length > 0 || facetCounts.topic.length > 0 || facetCounts.year.length > 0 || facetCounts.doctype.length > 0}
<div class="bg-surface border border-default rounded-card p-3 mt-3">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xs font-semibold text-dim uppercase tracking-wider">탐색 축</h2>
{#if hasAnyFacet}
<button
onclick={clearAllFacets}
class="text-[10px] text-dim hover:text-accent"
>
초기화
</button>
{/if}
</div>
{#each Object.entries(FACET_LABELS) as [key, label]}
{@const items = facetCounts[key] || []}
{@const activeValue = key === 'company' ? activeFacetCompany : key === 'topic' ? activeFacetTopic : key === 'year' ? activeFacetYear : activeFacetDoctype}
{@const paramKey = `facet_${key}`}
{#if items.length > 0}
<div class="mb-2.5">
<h3 class="text-[10px] text-faint uppercase mb-1">{label}</h3>
<div class="flex flex-wrap gap-1">
{#each items as item}
{@const isActive = activeValue === String(item.value)}
{@const isDisabled = item.count === 0 && !isActive}
<button
onclick={() => toggleFacet(paramKey, item.value)}
disabled={isDisabled}
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] border transition-colors
{isActive
? 'bg-accent/15 border-accent/30 text-accent'
: isDisabled
? 'border-default text-faint cursor-not-allowed opacity-50'
: 'border-default text-dim hover:border-accent/30 hover:text-text'}"
>
<span class="truncate max-w-[120px]">{item.value}</span>
<span class="text-[9px] {isActive ? 'text-accent/70' : 'text-faint'}">{item.count}</span>
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</aside>
<!-- 오른쪽: 문서 목록 (7/12) -->