diff --git a/app/api/study_topics.py b/app/api/study_topics.py index 6477b97..f994655 100644 --- a/app/api/study_topics.py +++ b/app/api/study_topics.py @@ -102,7 +102,13 @@ class StudyTopicSessionSummary(BaseModel): class StudyTopicDocumentSummary(BaseModel): - """상세 뷰의 자료 카드 페이로드.""" + """상세 뷰의 자료 카드 페이로드. + + library_paths: 통합뷰 자료 섹션의 카테고리 트리 그룹핑용. user_tags 의 + `@library/` 태그에서 prefix 제거한 path 들. 한 자료가 여러 카테고리에 + 속할 수 있으나(다중 분류), 트리 그룹핑 시 첫 번째 path 만 사용. 빈 리스트면 + "분류 없음" 그룹. + """ id: int title: str | None @@ -113,6 +119,7 @@ class StudyTopicDocumentSummary(BaseModel): importance: str | None sort_order: int linked_at: datetime + library_paths: list[str] = [] class StudyTopicQuestionSummary(BaseModel): @@ -429,6 +436,15 @@ async def get_study_topic( ) ).all() + def _extract_library_paths(tags: list | None) -> list[str]: + if not tags: + return [] + out: list[str] = [] + for t in tags: + if isinstance(t, str) and t.startswith(LIBRARY_PREFIX): + out.append(t[len(LIBRARY_PREFIX):]) + return out + documents_payload = [ StudyTopicDocumentSummary( id=d.id, @@ -440,6 +456,7 @@ async def get_study_topic( importance=d.importance, sort_order=link.sort_order, linked_at=link.created_at, + library_paths=_extract_library_paths(d.user_tags), ) for d, link in doc_rows ] diff --git a/frontend/src/routes/study/topics/[id]/+page.svelte b/frontend/src/routes/study/topics/[id]/+page.svelte index a47a298..8de71f8 100644 --- a/frontend/src/routes/study/topics/[id]/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/+page.svelte @@ -43,6 +43,80 @@ let docPage = $state(1); let docTotal = $state(0); + // 통합뷰 자료 섹션 트리 그룹핑 (자료실 @library/ 기준) + 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 @@
-
+

자료 {detail.sections.documents.length} @@ -338,30 +412,93 @@ 이 주제에 연결된 자료가 없습니다. "자료 추가" 로 자료실 자료를 묶어보세요.

{:else} -
- {#each detail.sections.documents as d (d.id)} -
-
-
- {d.title || `(제목 없음 · #${d.id})`} -
-
- {d.file_format} - {#if d.category}· {d.category}{/if} - {#if d.ai_domain}· {d.ai_domain}{/if} - {#if d.importance && d.importance !== 'medium'}· {d.importance}{/if} - {fmtDate(d.linked_at)} -
+ +
+ + 자료 {detail.sections.documents.length}개 + · 카테고리 {docTree.leafPathCount}개 + +
+ + · + +
+
+ + {#snippet docCard(d)} +
+
+
{d.title || `(제목 없음 · #${d.id})`}
+
+ {d.file_format} + {#if d.ai_domain}· {d.ai_domain}{/if} + {#if d.importance && d.importance !== 'medium'}· {d.importance}{/if} + {fmtDate(d.linked_at)}
- -
+ + +
+ {/snippet} + + {#snippet treeNode(node, depth)} + {@const isExpanded = docTreeExpanded[node.path] === true} + {@const totalUnder = nodeTotalCount(node)} +
+ + {#if isExpanded} +
+ {#each node.documents as d (d.id)} + {@render docCard(d)} + {/each} +
+ {#each [...node.children.values()] as child (child.path)} + {@render treeNode(child, depth + 1)} + {/each} + {/if} +
+ {/snippet} + +
+ {#each [...docTree.root.children.values()] as child (child.path)} + {@render treeNode(child, 0)} {/each} + + {#if docTree.unclassified.length > 0} + {@const isExp = docTreeExpanded['__unclassified__'] === true} + + {#if isExp} +
+ {#each docTree.unclassified as d (d.id)} + {@render docCard(d)} + {/each} +
+ {/if} + {/if}
{/if}