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:
@@ -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) -->
|
||||
|
||||
Reference in New Issue
Block a user