fix(study): 통합뷰 자료 섹션 카테고리 트리 그룹핑 + 접기

가스기사처럼 한 워크스페이스에 273건 자료가 묶이면 평면 리스트로 쭉 나열
되어 통합뷰가 무너졌음. /study/topics/[id] 자료 섹션을 자료실 카테고리
경로 기반 트리로 그룹핑하고 노드별 접기/펼치기 도입. 기본값 모두 접힘.

백엔드: StudyTopicDocumentSummary 에 library_paths(`@library/<path>` 태그
에서 prefix 제거) 필드 추가. 그룹핑은 첫 path 만 사용 (단순화).

프론트: documents 를 path segment 별로 트리 빌드 → snippet 재귀 렌더링.
헤더에 "자료 N개 · 카테고리 K개 · [모두 펼치기/접기]" 컨트롤. 분류 없는
자료는 "분류 없음" 그룹으로 별도. 자료 0건 path 는 자동 누락.

필기/문제 섹션은 분류축이 달라(certification/subject vs subject) 동일
트리 못 쓰므로 본 PR 범위 밖. 후속에서 패턴 일관성 검토.
This commit is contained in:
Hyungi Ahn
2026-04-28 08:14:58 +09:00
parent 4b7156061e
commit 0e2a430a6c
2 changed files with 177 additions and 23 deletions
@@ -43,6 +43,80 @@
let docPage = $state(1);
let docTotal = $state(0);
// 통합뷰 자료 섹션 트리 그룹핑 (자료실 @library/<path> 기준)
let docTreeExpanded = $state({}); // {fullPath: bool}
/**
* documents 를 첫 library_path 기준으로 트리 빌드.
* 한 자료가 여러 카테고리에 속해도 그룹핑은 첫 path 만 사용 (단순화).
*/
function buildDocTree(documents) {
const root = { name: '', path: '', children: new Map(), documents: [] };
const unclassified = [];
let pathSet = new Set();
for (const d of documents) {
const paths = d.library_paths ?? [];
if (paths.length === 0) {
unclassified.push(d);
continue;
}
const path = paths[0];
pathSet.add(path);
const segments = path.split('/');
let node = root;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const childPath = segments.slice(0, i + 1).join('/');
if (!node.children.has(seg)) {
node.children.set(seg, {
name: seg,
path: childPath,
children: new Map(),
documents: [],
});
}
node = node.children.get(seg);
}
node.documents.push(d);
}
return { root, unclassified, leafPathCount: pathSet.size + (unclassified.length > 0 ? 1 : 0) };
}
/** 노드 + 하위 모든 자료 수 합산 */
function nodeTotalCount(node) {
let n = node.documents.length;
for (const child of node.children.values()) n += nodeTotalCount(child);
return n;
}
/** 트리 내 모든 fullPath 수집 (expand all 용) */
function collectAllPaths(node, out) {
for (const child of node.children.values()) {
out.push(child.path);
collectAllPaths(child, out);
}
}
let docTree = $derived(buildDocTree(detail?.sections?.documents ?? []));
function expandAllDocs() {
const paths = [];
collectAllPaths(docTree.root, paths);
if (docTree.unclassified.length > 0) paths.push('__unclassified__');
const next = {};
for (const p of paths) next[p] = true;
docTreeExpanded = next;
}
function collapseAllDocs() {
docTreeExpanded = {};
}
function toggleDocPath(path) {
docTreeExpanded = { ...docTreeExpanded, [path]: !docTreeExpanded[path] };
}
async function load() {
loading = true;
try {
@@ -326,7 +400,7 @@
<!-- 자료 -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<BookOpen size={14} class="text-accent" /> 자료
<span class="text-[10px] text-dim">{detail.sections.documents.length}</span>
@@ -338,30 +412,93 @@
이 주제에 연결된 자료가 없습니다. "자료 추가" 로 자료실 자료를 묶어보세요.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each detail.sections.documents as d (d.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="text-sm text-text truncate">
{d.title || `(제목 없음 · #${d.id})`}
</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
<span>{d.file_format}</span>
{#if d.category}<span>· {d.category}</span>{/if}
{#if d.ai_domain}<span>· {d.ai_domain}</span>{/if}
{#if d.importance && d.importance !== 'medium'}<span>· {d.importance}</span>{/if}
<span class="ml-auto">{fmtDate(d.linked_at)}</span>
</div>
<!-- 트리 헤더: 총 N개 · 카테고리 K개 + 펼치기/접기 -->
<div class="flex items-center justify-between mb-2 px-1 text-[11px] text-dim flex-wrap gap-2">
<span>
자료 <span class="text-text">{detail.sections.documents.length}</span>
· 카테고리 <span class="text-text">{docTree.leafPathCount}</span>
</span>
<div class="flex items-center gap-2">
<button type="button" onclick={expandAllDocs} class="text-accent hover:underline">모두 펼치기</button>
<span class="text-faint">·</span>
<button type="button" onclick={collapseAllDocs} class="text-dim hover:text-text">모두 접기</button>
</div>
</div>
{#snippet docCard(d)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="text-sm text-text truncate">{d.title || `(제목 없음 · #${d.id})`}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
<span>{d.file_format}</span>
{#if d.ai_domain}<span>· {d.ai_domain}</span>{/if}
{#if d.importance && d.importance !== 'medium'}<span>· {d.importance}</span>{/if}
<span class="ml-auto">{fmtDate(d.linked_at)}</span>
</div>
<Button href={`/documents/${d.id}`} size="sm" variant="ghost" icon={ArrowRight} iconPosition="right">열기</Button>
<button
type="button"
onclick={() => unlinkDocument(d.id)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="주제에서 분리"
><Trash2 size={12} /></button>
</div>
<Button href={`/documents/${d.id}`} size="sm" variant="ghost" icon={ArrowRight} iconPosition="right">열기</Button>
<button
type="button"
onclick={() => unlinkDocument(d.id)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="주제에서 분리"
><Trash2 size={12} /></button>
</div>
{/snippet}
{#snippet treeNode(node, depth)}
{@const isExpanded = docTreeExpanded[node.path] === true}
{@const totalUnder = nodeTotalCount(node)}
<div class="flex flex-col gap-1">
<button
type="button"
onclick={() => toggleDocPath(node.path)}
style="padding-left: {depth * 12}px"
class="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-text hover:bg-bg transition-colors"
>
{#if isExpanded}<ChevronDown size={12} class="text-dim shrink-0" />{:else}<ChevronRight size={12} class="text-dim shrink-0" />{/if}
<FolderOpen size={12} class="text-dim shrink-0" />
<span class="truncate">{node.name}</span>
<span class="ml-auto text-[10px] text-dim shrink-0">{totalUnder}</span>
</button>
{#if isExpanded}
<div class="flex flex-col gap-1.5" style="padding-left: {(depth + 1) * 12}px">
{#each node.documents as d (d.id)}
{@render docCard(d)}
{/each}
</div>
{#each [...node.children.values()] as child (child.path)}
{@render treeNode(child, depth + 1)}
{/each}
{/if}
</div>
{/snippet}
<div class="flex flex-col gap-1">
{#each [...docTree.root.children.values()] as child (child.path)}
{@render treeNode(child, 0)}
{/each}
{#if docTree.unclassified.length > 0}
{@const isExp = docTreeExpanded['__unclassified__'] === true}
<button
type="button"
onclick={() => toggleDocPath('__unclassified__')}
class="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-text hover:bg-bg transition-colors"
>
{#if isExp}<ChevronDown size={12} class="text-dim shrink-0" />{:else}<ChevronRight size={12} class="text-dim shrink-0" />{/if}
<FolderOpen size={12} class="text-dim shrink-0" />
<span class="truncate text-dim italic">분류 없음</span>
<span class="ml-auto text-[10px] text-dim shrink-0">{docTree.unclassified.length}</span>
</button>
{#if isExp}
<div class="flex flex-col gap-1.5" style="padding-left: 12px">
{#each docTree.unclassified as d (d.id)}
{@render docCard(d)}
{/each}
</div>
{/if}
{/if}
</div>
{/if}
</section>