feat(library): 자료실 분류 체계 독립 관리 Phase 1
library_categories 테이블 추가로 빈 카테고리 생성 가능. CRUD API (생성/leaf rename/leaf delete) + 트리 머지 엔드포인트. 사이드바 트리에 컨텍스트 메뉴 (추가/이름변경/삭제). LibraryPathEditor를 카테고리 기반 flat selector로 전환. 미분류는 시스템 분류로 보호 (삭제/이름변경 불가). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""자료실 분류 체계 CRUD API — /api/library"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path
|
||||
from models.category import LibraryCategory
|
||||
from models.document import Document
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── 스키마 ───
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class CategoryRename(BaseModel):
|
||||
path: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
path: str
|
||||
name: str
|
||||
parent_path: str | None
|
||||
depth: int
|
||||
is_system: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryTreeNode(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
count: int
|
||||
is_category: bool
|
||||
is_system: bool
|
||||
has_children: bool
|
||||
children: list["CategoryTreeNode"]
|
||||
|
||||
|
||||
# ─── 엔드포인트 ───
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryResponse])
|
||||
async def list_categories(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""전체 카테고리 flat 목록 (path 순)"""
|
||||
result = await session.execute(
|
||||
select(LibraryCategory).order_by(LibraryCategory.path)
|
||||
)
|
||||
return [CategoryResponse.model_validate(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryResponse, status_code=201)
|
||||
async def create_category(
|
||||
body: CategoryCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리 생성 (조상 자동 생성 포함)"""
|
||||
try:
|
||||
normalized = normalize_library_path(body.path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
segments = normalized.split("/")
|
||||
if len(segments) > MAX_DEPTH:
|
||||
raise HTTPException(status_code=400, detail=f"최대 {MAX_DEPTH}단계까지 가능")
|
||||
|
||||
# 중복 검사
|
||||
existing = await session.execute(
|
||||
select(LibraryCategory).where(LibraryCategory.path == normalized)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="이미 존재하는 분류 경로")
|
||||
|
||||
# 조상 자동 생성
|
||||
for i in range(1, len(segments)):
|
||||
ancestor_path = "/".join(segments[:i])
|
||||
ancestor_name = segments[i - 1]
|
||||
ancestor_parent = "/".join(segments[: i - 1]) or None
|
||||
exists = await session.execute(
|
||||
select(LibraryCategory.id).where(
|
||||
LibraryCategory.path == ancestor_path
|
||||
)
|
||||
)
|
||||
if not exists.scalar_one_or_none():
|
||||
session.add(LibraryCategory(
|
||||
path=ancestor_path,
|
||||
name=ancestor_name,
|
||||
parent_path=ancestor_parent,
|
||||
depth=i,
|
||||
))
|
||||
|
||||
# 본 카테고리 생성
|
||||
category = LibraryCategory(
|
||||
path=normalized,
|
||||
name=segments[-1],
|
||||
parent_path="/".join(segments[:-1]) or None,
|
||||
depth=len(segments),
|
||||
)
|
||||
session.add(category)
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.patch("/categories", response_model=CategoryResponse)
|
||||
async def rename_category(
|
||||
body: CategoryRename,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리 이름 변경 (leaf only, path 기반 식별)"""
|
||||
# 카테고리 조회
|
||||
result = await session.execute(
|
||||
select(LibraryCategory).where(LibraryCategory.path == body.path)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다")
|
||||
|
||||
# 시스템 분류 보호
|
||||
if category.is_system:
|
||||
raise HTTPException(status_code=422, detail="시스템 분류는 변경할 수 없습니다")
|
||||
|
||||
# leaf 검사
|
||||
children = await session.execute(
|
||||
select(func.count()).where(
|
||||
LibraryCategory.parent_path == category.path
|
||||
)
|
||||
)
|
||||
if children.scalar() > 0:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="하위 분류가 있어 이름을 변경할 수 없습니다"
|
||||
)
|
||||
|
||||
# new_name 검증
|
||||
new_name = body.new_name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="빈 이름")
|
||||
if len(new_name) > 30:
|
||||
raise HTTPException(status_code=400, detail="이름은 30자 이하")
|
||||
|
||||
# 새 path 계산
|
||||
new_path = (
|
||||
f"{category.parent_path}/{new_name}" if category.parent_path else new_name
|
||||
)
|
||||
|
||||
# 중복 검사
|
||||
dup = await session.execute(
|
||||
select(LibraryCategory.id).where(LibraryCategory.path == new_path)
|
||||
)
|
||||
if dup.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="같은 이름의 분류가 이미 존재합니다")
|
||||
|
||||
old_tag = f"{LIBRARY_PREFIX}{category.path}"
|
||||
new_tag = f"{LIBRARY_PREFIX}{new_path}"
|
||||
|
||||
# 문서 태그 갱신
|
||||
await session.execute(
|
||||
sql_text("""
|
||||
UPDATE documents
|
||||
SET user_tags = COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem = :old_tag THEN :new_tag ELSE elem END
|
||||
)
|
||||
FROM jsonb_array_elements_text(
|
||||
COALESCE(user_tags, '[]'::jsonb)
|
||||
) AS elem
|
||||
), '[]'::jsonb)
|
||||
WHERE user_tags @> :old_tag_jsonb
|
||||
""").bindparams(
|
||||
old_tag=old_tag,
|
||||
new_tag=new_tag,
|
||||
old_tag_jsonb=f'["{old_tag}"]',
|
||||
)
|
||||
)
|
||||
|
||||
# 카테고리 row 갱신 (path, name만. parent_path 유지)
|
||||
category.path = new_path
|
||||
category.name = new_name
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/categories", status_code=204)
|
||||
async def delete_category(
|
||||
path: str = Query(..., description="삭제할 카테고리 경로"),
|
||||
user: Annotated[User, Depends(get_current_user)] = None,
|
||||
session: Annotated[AsyncSession, Depends(get_session)] = None,
|
||||
):
|
||||
"""카테고리 삭제 (leaf only, 문서 없는 경우만)"""
|
||||
result = await session.execute(
|
||||
select(LibraryCategory).where(LibraryCategory.path == path)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다")
|
||||
|
||||
if category.is_system:
|
||||
raise HTTPException(status_code=422, detail="시스템 분류는 삭제할 수 없습니다")
|
||||
|
||||
# leaf 검사
|
||||
children = await session.execute(
|
||||
select(func.count()).where(
|
||||
LibraryCategory.parent_path == category.path
|
||||
)
|
||||
)
|
||||
if children.scalar() > 0:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="하위 분류가 있어 삭제할 수 없습니다"
|
||||
)
|
||||
|
||||
# 문서 연결 검사
|
||||
tag = f"{LIBRARY_PREFIX}{category.path}"
|
||||
doc_count = await session.execute(
|
||||
sql_text("""
|
||||
SELECT COUNT(*) FROM documents
|
||||
WHERE deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(
|
||||
COALESCE(user_tags, '[]'::jsonb)
|
||||
) AS t
|
||||
WHERE t = :tag
|
||||
)
|
||||
""").bindparams(tag=tag)
|
||||
)
|
||||
if doc_count.scalar() > 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="이 분류에 속한 문서가 있어 삭제할 수 없습니다. 문서를 먼저 이동하세요.",
|
||||
)
|
||||
|
||||
await session.delete(category)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[CategoryTreeNode])
|
||||
async def get_library_tree(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리 저장소 + 문서 태그 count 머지 트리"""
|
||||
|
||||
# 1. 카테고리 전체 fetch
|
||||
cat_result = await session.execute(
|
||||
select(LibraryCategory).order_by(LibraryCategory.path)
|
||||
)
|
||||
categories = cat_result.scalars().all()
|
||||
|
||||
# path → category 매핑
|
||||
cat_map: dict[str, LibraryCategory] = {c.path: c for c in categories}
|
||||
|
||||
# 2. 문서 태그에서 doc count 집계
|
||||
doc_result = await session.execute(
|
||||
select(Document.id, Document.user_tags).where(
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
Document.user_tags != None, # noqa: E711
|
||||
)
|
||||
)
|
||||
|
||||
# path → set of doc_ids
|
||||
path_docs: dict[str, set[int]] = {}
|
||||
for doc_id, tags in doc_result:
|
||||
if not tags:
|
||||
continue
|
||||
seen_ancestors: set[str] = set()
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str) or not tag.startswith(LIBRARY_PREFIX):
|
||||
continue
|
||||
path = tag[len(LIBRARY_PREFIX):]
|
||||
parts = path.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
ancestor = "/".join(parts[:i])
|
||||
if ancestor not in seen_ancestors:
|
||||
path_docs.setdefault(ancestor, set()).add(doc_id)
|
||||
seen_ancestors.add(ancestor)
|
||||
|
||||
# 3. 모든 path 합산 (카테고리 + 태그)
|
||||
all_paths = set(cat_map.keys()) | set(path_docs.keys())
|
||||
|
||||
# 4. 트리 구축
|
||||
root: dict = {}
|
||||
for p in sorted(all_paths):
|
||||
parts = p.split("/")
|
||||
node = root
|
||||
for i, part in enumerate(parts):
|
||||
if part not in node:
|
||||
node[part] = {"_children": {}}
|
||||
node = node[part]["_children"] if i < len(parts) - 1 else node[part]
|
||||
|
||||
def build_tree(d: dict, prefix: str = "") -> list[dict]:
|
||||
nodes = []
|
||||
for name, data in sorted(d.items()):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
path = f"{prefix}/{name}" if prefix else name
|
||||
children_dict = data.get("_children", {})
|
||||
children = build_tree(children_dict, path)
|
||||
cat = cat_map.get(path)
|
||||
nodes.append(CategoryTreeNode(
|
||||
name=name,
|
||||
path=path,
|
||||
count=len(path_docs.get(path, set())),
|
||||
is_category=path in cat_map,
|
||||
is_system=cat.is_system if cat else False,
|
||||
has_children=len(children) > 0,
|
||||
children=children,
|
||||
))
|
||||
return nodes
|
||||
|
||||
return build_tree(root)
|
||||
@@ -10,6 +10,7 @@ from api.auth import router as auth_router
|
||||
from api.dashboard import router as dashboard_router
|
||||
from api.digest import router as digest_router
|
||||
from api.documents import router as documents_router
|
||||
from api.library import router as library_router
|
||||
from api.memos import router as memos_router
|
||||
from api.news import router as news_router
|
||||
from api.search import router as search_router
|
||||
@@ -92,6 +93,7 @@ app.include_router(search_router, prefix="/api/search", tags=["search"])
|
||||
|
||||
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
app.include_router(library_router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""library_categories 테이블 ORM — 자료실 분류 체계 독립 관리"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class LibraryCategory(Base):
|
||||
__tablename__ = "library_categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parent_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
depth: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
@@ -19,6 +19,8 @@
|
||||
let editing = $state(false);
|
||||
let existingPaths = $state([]);
|
||||
let showSuggestions = $state(false);
|
||||
let showCategoryPicker = $state(false);
|
||||
let categoryFilter = $state('');
|
||||
|
||||
let suggestions = $derived(
|
||||
newPath.length >= 1
|
||||
@@ -30,6 +32,14 @@
|
||||
: []
|
||||
);
|
||||
|
||||
let filteredCategories = $derived(
|
||||
categoryFilter
|
||||
? existingPaths.filter(
|
||||
(p) => p.toLowerCase().includes(categoryFilter.toLowerCase()) && !libraryPaths.includes(p)
|
||||
)
|
||||
: existingPaths.filter((p) => !libraryPaths.includes(p))
|
||||
);
|
||||
|
||||
// 입력 정규화 미리보기
|
||||
let normalizedPreview = $derived(() => {
|
||||
try {
|
||||
@@ -48,23 +58,13 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const tree = await api('/documents/library-tree');
|
||||
existingPaths = flattenPaths(tree);
|
||||
const categories = await api('/library/categories');
|
||||
existingPaths = categories.map((c) => c.path);
|
||||
} catch {
|
||||
/* 자동완성 실패해도 입력은 가능 */
|
||||
}
|
||||
});
|
||||
|
||||
function flattenPaths(nodes, prefix = '') {
|
||||
let paths = [];
|
||||
for (const n of nodes) {
|
||||
const p = prefix ? `${prefix}/${n.name}` : n.name;
|
||||
paths.push(p);
|
||||
if (n.children?.length) paths.push(...flattenPaths(n.children, p));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function normalizePath(raw) {
|
||||
const stripped = raw.trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!stripped) throw new Error('빈 경로');
|
||||
@@ -205,12 +205,45 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="flex items-center gap-1 text-xs text-dim hover:text-accent"
|
||||
>
|
||||
<Plus size={12} /> 경로 추가
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCategoryPicker = !showCategoryPicker)}
|
||||
class="flex items-center gap-1 text-xs text-dim hover:text-accent"
|
||||
>
|
||||
<Plus size={12} /> 분류 선택
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editing = true)}
|
||||
class="text-[10px] text-faint hover:text-dim"
|
||||
>
|
||||
직접 입력
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 카테고리 선택기 -->
|
||||
{#if showCategoryPicker}
|
||||
<div class="mt-1.5 border border-default rounded bg-surface max-h-48 overflow-y-auto">
|
||||
<input
|
||||
bind:value={categoryFilter}
|
||||
placeholder="분류 검색..."
|
||||
class="w-full px-2 py-1.5 bg-bg border-b border-default text-xs text-text outline-none focus:border-accent sticky top-0"
|
||||
/>
|
||||
{#if filteredCategories.length === 0}
|
||||
<p class="px-2 py-2 text-[10px] text-faint">일치하는 분류 없음</p>
|
||||
{:else}
|
||||
{#each filteredCategories as path}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { addPath(path); showCategoryPicker = false; categoryFilter = ''; }}
|
||||
class="w-full text-left px-2 py-1.5 text-xs text-dim hover:bg-surface-hover hover:text-text"
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
// 자료실 — 태그 기반 트리 문서 관리
|
||||
// 자료실 — 분류 체계 + 트리 문서 관리
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
@@ -13,11 +14,18 @@
|
||||
Download,
|
||||
FileText,
|
||||
Upload,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-svelte';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
import FormatIcon from '$lib/components/FormatIcon.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
// ─── 상태 ───
|
||||
@@ -43,13 +51,31 @@
|
||||
|
||||
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
|
||||
const DEFAULT_LIBRARY_PATH = '미분류';
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
// ─── 카테고리 CRUD 상태 ───
|
||||
|
||||
let addParentPath = $state(null);
|
||||
let newCategoryName = $state('');
|
||||
let addLoading = $state(false);
|
||||
|
||||
let renameTarget = $state(null); // { path, name }
|
||||
let renameValue = $state('');
|
||||
let renameLoading = $state(false);
|
||||
|
||||
let deleteTarget = $state(null); // { path, name }
|
||||
|
||||
// 컨텍스트 메뉴
|
||||
let contextNode = $state(null);
|
||||
let showContextMenu = $state(false);
|
||||
let contextPos = $state({ x: 0, y: 0 });
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
async function loadTree() {
|
||||
treeLoading = true;
|
||||
try {
|
||||
tree = await api('/documents/library-tree');
|
||||
tree = await api('/library/tree');
|
||||
} catch {
|
||||
addToast('error', '자료실 트리 로딩 실패');
|
||||
} finally {
|
||||
@@ -217,6 +243,108 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 카테고리 CRUD ───
|
||||
|
||||
function openContextMenu(e, node) {
|
||||
e.stopPropagation();
|
||||
contextNode = node;
|
||||
contextPos = { x: e.clientX, y: e.clientY };
|
||||
showContextMenu = true;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
showContextMenu = false;
|
||||
contextNode = null;
|
||||
}
|
||||
|
||||
function openAddCategory(parentPath) {
|
||||
addParentPath = parentPath;
|
||||
newCategoryName = '';
|
||||
closeContextMenu();
|
||||
ui.openModal('add-category');
|
||||
}
|
||||
|
||||
function openRenameCategory(node) {
|
||||
renameTarget = { path: node.path, name: node.name };
|
||||
renameValue = node.name;
|
||||
closeContextMenu();
|
||||
ui.openModal('rename-category');
|
||||
}
|
||||
|
||||
function openDeleteCategory(node) {
|
||||
deleteTarget = { path: node.path, name: node.name };
|
||||
closeContextMenu();
|
||||
ui.openModal('delete-category');
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
const name = newCategoryName.trim();
|
||||
if (!name) return;
|
||||
const fullPath = addParentPath ? `${addParentPath}/${name}` : name;
|
||||
addLoading = true;
|
||||
try {
|
||||
await api('/library/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: fullPath }),
|
||||
});
|
||||
addToast('success', `"${name}" 분류 생성됨`);
|
||||
ui.closeModal('add-category');
|
||||
loadTree();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '분류 생성 실패');
|
||||
} finally {
|
||||
addLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renameCategory() {
|
||||
const name = renameValue.trim();
|
||||
if (!name || !renameTarget) return;
|
||||
renameLoading = true;
|
||||
try {
|
||||
const result = await api('/library/categories', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ path: renameTarget.path, new_name: name }),
|
||||
});
|
||||
addToast('success', `"${renameTarget.name}" → "${name}" 변경됨`);
|
||||
ui.closeModal('rename-category');
|
||||
// 현재 선택 경로가 변경된 경우 URL 갱신
|
||||
if (activePath === renameTarget.path) {
|
||||
navigate(result.path);
|
||||
}
|
||||
loadTree();
|
||||
loadDocs();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '이름 변경 실패');
|
||||
} finally {
|
||||
renameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api(`/library/categories?path=${encodeURIComponent(deleteTarget.path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
addToast('success', `"${deleteTarget.name}" 분류 삭제됨`);
|
||||
ui.closeModal('delete-category');
|
||||
if (activePath === deleteTarget.path) {
|
||||
navigate(null);
|
||||
}
|
||||
loadTree();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 클릭으로 컨텍스트 메뉴 닫기
|
||||
onMount(() => {
|
||||
function onClickOutside() { showContextMenu = false; }
|
||||
window.addEventListener('click', onClickOutside);
|
||||
return () => window.removeEventListener('click', onClickOutside);
|
||||
});
|
||||
|
||||
// ─── 검색 debounce ───
|
||||
|
||||
let searchInput = $state(activeQ);
|
||||
@@ -258,20 +386,29 @@
|
||||
<div class="bg-surface border border-default rounded-card p-3">
|
||||
<h2 class="text-xs font-semibold text-dim uppercase tracking-wider mb-2">분류</h2>
|
||||
|
||||
<!-- 전체 -->
|
||||
<button
|
||||
onclick={() => navigate(null)}
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors mb-1
|
||||
{!activePath ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
전체
|
||||
</span>
|
||||
{#if totalCount > 0}
|
||||
<span class="text-xs text-dim">{totalCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- 전체 + 새 분류 버튼 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<button
|
||||
onclick={() => navigate(null)}
|
||||
class="flex-1 flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
|
||||
{!activePath ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface-hover'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
전체
|
||||
</span>
|
||||
{#if totalCount > 0}
|
||||
<span class="text-xs text-dim">{totalCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openAddCategory(null)}
|
||||
class="p-1.5 rounded hover:bg-surface-hover text-dim hover:text-accent ml-1"
|
||||
title="새 분류 추가"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 트리 -->
|
||||
{#if treeLoading}
|
||||
@@ -289,10 +426,11 @@
|
||||
{#snippet treeNode(n, depth)}
|
||||
{@const isActive = activePath === n.path}
|
||||
{@const isParent = activePath?.startsWith(n.path + '/')}
|
||||
{@const hasChildren = n.children.length > 0}
|
||||
{@const hasChildren = n.has_children || n.children.length > 0}
|
||||
{@const isExpanded = expanded[n.path]}
|
||||
{@const nodeDepth = n.path.split('/').length}
|
||||
|
||||
<div class="flex items-center" style="padding-left: {depth * 16}px">
|
||||
<div class="group flex items-center" style="padding-left: {depth * 16}px">
|
||||
{#if hasChildren}
|
||||
<button
|
||||
onclick={() => toggleExpand(n.path)}
|
||||
@@ -320,6 +458,15 @@
|
||||
<span class="truncate">{n.name}</span>
|
||||
<span class="text-xs text-dim shrink-0 ml-2">{n.count}</span>
|
||||
</button>
|
||||
|
||||
<!-- 카테고리 액션 메뉴 -->
|
||||
<button
|
||||
onclick={(e) => openContextMenu(e, n)}
|
||||
class="p-0.5 rounded hover:bg-surface-hover text-dim opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="분류 관리"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasChildren && isExpanded}
|
||||
@@ -491,6 +638,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 컨텍스트 메뉴 -->
|
||||
{#if showContextMenu && contextNode}
|
||||
{@const cn = contextNode}
|
||||
{@const cnDepth = cn.path.split('/').length}
|
||||
<div
|
||||
class="fixed z-50 bg-surface border border-default rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
style="left: {contextPos.x}px; top: {contextPos.y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if cnDepth < MAX_DEPTH}
|
||||
<button
|
||||
onclick={() => openAddCategory(cn.path)}
|
||||
class="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-hover flex items-center gap-2"
|
||||
>
|
||||
<FolderPlus size={12} /> 하위 분류 추가
|
||||
</button>
|
||||
{/if}
|
||||
{#if cn.is_category && !cn.is_system && !cn.has_children}
|
||||
<button
|
||||
onclick={() => openRenameCategory(cn)}
|
||||
class="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-hover flex items-center gap-2"
|
||||
>
|
||||
<Pencil size={12} /> 이름 변경
|
||||
</button>
|
||||
{/if}
|
||||
{#if cn.is_category && !cn.is_system && !cn.has_children && cn.count === 0}
|
||||
<button
|
||||
onclick={() => openDeleteCategory(cn)}
|
||||
class="w-full text-left px-3 py-1.5 text-xs text-error hover:bg-surface-hover flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={12} /> 삭제
|
||||
</button>
|
||||
{/if}
|
||||
{#if cn.is_system || cn.has_children}
|
||||
<p class="px-3 py-1.5 text-[10px] text-faint">
|
||||
{cn.is_system ? '시스템 분류' : '하위 분류 있음'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 분류 추가 모달 -->
|
||||
<Modal id="add-category" title={addParentPath ? `"${addParentPath}" 하위 분류 추가` : '새 분류 추가'} size="sm">
|
||||
<form onsubmit={(e) => { e.preventDefault(); createCategory(); }}>
|
||||
<label class="block text-xs text-dim mb-1">분류 이름</label>
|
||||
<input
|
||||
bind:value={newCategoryName}
|
||||
placeholder="분류 이름 입력"
|
||||
class="w-full px-3 py-2 bg-bg border border-default rounded text-sm text-text outline-none focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
{#if addParentPath}
|
||||
<p class="text-[10px] text-faint mt-1">경로: {addParentPath}/{newCategoryName || '...'}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('add-category')}>취소</Button>
|
||||
<Button variant="primary" size="sm" onclick={createCategory} disabled={!newCategoryName.trim() || addLoading}>
|
||||
{addLoading ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- 이름 변경 모달 -->
|
||||
<Modal id="rename-category" title="분류 이름 변경" size="sm">
|
||||
<form onsubmit={(e) => { e.preventDefault(); renameCategory(); }}>
|
||||
<label class="block text-xs text-dim mb-1">새 이름</label>
|
||||
<input
|
||||
bind:value={renameValue}
|
||||
placeholder="새 이름 입력"
|
||||
class="w-full px-3 py-2 bg-bg border border-default rounded text-sm text-text outline-none focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
{#if renameTarget}
|
||||
<p class="text-[10px] text-faint mt-1">현재: {renameTarget.name}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('rename-category')}>취소</Button>
|
||||
<Button variant="primary" size="sm" onclick={renameCategory} disabled={!renameValue.trim() || renameLoading}>
|
||||
{renameLoading ? '변경 중...' : '변경'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<ConfirmDialog
|
||||
id="delete-category"
|
||||
title="분류 삭제"
|
||||
message={deleteTarget ? `"${deleteTarget.name}" 분류를 삭제하시겠습니까?` : ''}
|
||||
tone="danger"
|
||||
onconfirm={deleteCategory}
|
||||
/>
|
||||
|
||||
<!-- 드래그 업로드 오버레이 -->
|
||||
{#if dragging}
|
||||
<div class="fixed inset-0 z-50 bg-accent/10 border-2 border-dashed border-accent flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 자료실 분류 독립 관리 테이블
|
||||
CREATE TABLE library_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_path TEXT,
|
||||
depth INT NOT NULL DEFAULT 1,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_library_categories_path ON library_categories (path);
|
||||
CREATE INDEX idx_library_categories_parent ON library_categories (parent_path);
|
||||
|
||||
-- 시스템 분류: 미분류
|
||||
INSERT INTO library_categories (path, name, parent_path, depth, is_system)
|
||||
VALUES ('미분류', '미분류', NULL, 1, TRUE);
|
||||
Reference in New Issue
Block a user