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