feat(library): 자료실 — 태그 기반 트리 문서 관리 기능
목적성 문서(양식, 템플릿, 연간보고서)를 @library/ 태그로 분류하고 트리 구조로 탐색하는 자료실 페이지 추가. 백엔드: 경로 정규화 유틸, library-tree/library 엔드포인트, 다운로드 Content-Disposition 개선(원본/PDF 분리, 한글 filename*) 프론트: /library 페이지, LibraryPathEditor, 상단 nav/사이드바 링크 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+159
-2
@@ -4,6 +4,7 @@ import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -74,6 +75,7 @@ class DocumentUpdate(BaseModel):
|
||||
ai_domain: str | None = None
|
||||
ai_sub_group: str | None = None
|
||||
ai_tags: list | None = None
|
||||
user_tags: list | None = None
|
||||
user_note: str | None = None
|
||||
is_read: bool | None = None
|
||||
edit_url: str | None = None
|
||||
@@ -141,6 +143,132 @@ async def get_document_tree(
|
||||
return build_tree(root)
|
||||
|
||||
|
||||
@router.get("/library-tree")
|
||||
async def get_library_tree(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""자료실 트리 (user_tags @library/ 경로 기반, unique doc count)"""
|
||||
from core.library import LIBRARY_PREFIX
|
||||
|
||||
result = await session.execute(
|
||||
select(Document.id, Document.user_tags).where(
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
Document.user_tags != None, # noqa: E711
|
||||
)
|
||||
)
|
||||
|
||||
root: dict = {}
|
||||
for doc_id, tags in 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("/")
|
||||
node = root
|
||||
for i, part in enumerate(parts):
|
||||
if part not in node:
|
||||
node[part] = {"_docs": set(), "_children": {}}
|
||||
ancestor_key = "/".join(parts[: i + 1])
|
||||
if ancestor_key not in seen_ancestors:
|
||||
node[part]["_docs"].add(doc_id)
|
||||
seen_ancestors.add(ancestor_key)
|
||||
node = node[part]["_children"]
|
||||
|
||||
def build_library_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 = build_library_tree(data["_children"], path)
|
||||
nodes.append({
|
||||
"name": name,
|
||||
"path": path,
|
||||
"count": len(data["_docs"]),
|
||||
"children": children,
|
||||
})
|
||||
return nodes
|
||||
|
||||
return build_library_tree(root)
|
||||
|
||||
|
||||
@router.get("/library", response_model=DocumentListResponse)
|
||||
async def list_library_documents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
path: str | None = None,
|
||||
q: str | None = None,
|
||||
sort: str = Query("updated_desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""자료실 문서 목록 (prefix match, title 검색, 정렬)"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from core.library import LIBRARY_PREFIX, normalize_library_path
|
||||
|
||||
# path 쿼리 정규화 (PATCH와 동일 semantics)
|
||||
if path:
|
||||
try:
|
||||
path = normalize_library_path(path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
query = select(Document).where(
|
||||
Document.deleted_at == None, # noqa: E711
|
||||
)
|
||||
|
||||
if path:
|
||||
exact = f"{LIBRARY_PREFIX}{path}"
|
||||
prefix = f"{LIBRARY_PREFIX}{path}/%"
|
||||
query = query.where(
|
||||
sql_text("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(documents.user_tags) AS t
|
||||
WHERE t = :exact OR t LIKE :prefix
|
||||
)
|
||||
""").bindparams(exact=exact, prefix=prefix)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
sql_text("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(documents.user_tags) AS t
|
||||
WHERE t LIKE '@library/%'
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
if q:
|
||||
query = query.where(Document.title.ilike(f"%{q}%"))
|
||||
|
||||
# 전체 건수
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await session.execute(count_query)).scalar()
|
||||
|
||||
# 정렬
|
||||
sort_map = {
|
||||
"updated_desc": Document.updated_at.desc(),
|
||||
"title_asc": Document.title.asc(),
|
||||
"created_desc": Document.created_at.desc(),
|
||||
}
|
||||
query = query.order_by(sort_map.get(sort, Document.updated_at.desc()))
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return DocumentListResponse(
|
||||
items=[DocumentResponse.model_validate(doc) for doc in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
@@ -206,6 +334,7 @@ async def get_document_file(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
download: bool = Query(False, description="true면 attachment (브라우저 다운로드)"),
|
||||
user: User | None = Depends(lambda: None),
|
||||
):
|
||||
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
|
||||
@@ -246,10 +375,19 @@ async def get_document_file(
|
||||
suffix = file_path.suffix.lower()
|
||||
media_type = media_types.get(suffix, "application/octet-stream")
|
||||
|
||||
# Content-Disposition: download=true면 attachment (한글 filename* 호환)
|
||||
if download:
|
||||
raw_title = doc.title or f"document-{doc_id}"
|
||||
ascii_fallback = raw_title.encode("ascii", "replace").decode()
|
||||
utf8_encoded = quote(f"{raw_title}{suffix}")
|
||||
disposition = f'attachment; filename="{ascii_fallback}{suffix}"; filename*=UTF-8\'\'{utf8_encoded}'
|
||||
else:
|
||||
disposition = "inline"
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": "inline"},
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@@ -324,11 +462,21 @@ async def update_document(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 메타데이터 수정 (수동 오버라이드)"""
|
||||
from core.library import validate_user_tags
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
# user_tags 검증: @library/ 경로 정규화 + 타입/중복 체크
|
||||
if "user_tags" in update_data and update_data["user_tags"] is not None:
|
||||
try:
|
||||
update_data["user_tags"] = validate_user_tags(update_data["user_tags"])
|
||||
except (TypeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
@@ -375,6 +523,7 @@ async def get_document_preview(
|
||||
doc_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
token: str | None = Query(None, description="Bearer token (iframe용)"),
|
||||
download: bool = Query(False, description="true면 attachment (PDF 다운로드)"),
|
||||
):
|
||||
"""PDF 미리보기 캐시 서빙"""
|
||||
from core.auth import decode_token
|
||||
@@ -394,10 +543,18 @@ async def get_document_preview(
|
||||
if not preview_path.exists():
|
||||
raise HTTPException(status_code=404, detail="미리보기가 아직 생성되지 않았습니다")
|
||||
|
||||
if download:
|
||||
raw_title = doc.title or f"document-{doc_id}"
|
||||
ascii_fallback = raw_title.encode("ascii", "replace").decode()
|
||||
utf8_encoded = quote(f"{raw_title}.pdf")
|
||||
disposition = f'attachment; filename="{ascii_fallback}.pdf"; filename*=UTF-8\'\'{utf8_encoded}'
|
||||
else:
|
||||
disposition = "inline"
|
||||
|
||||
return FileResponse(
|
||||
path=str(preview_path),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "inline"},
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""자료실 경로 유틸.
|
||||
|
||||
user_tags 내 @library/ 접두사 태그를 정규화·검증·추출한다.
|
||||
"""
|
||||
|
||||
LIBRARY_PREFIX = "@library/"
|
||||
MAX_DEPTH = 5
|
||||
MAX_SEGMENT_LEN = 30
|
||||
|
||||
|
||||
def normalize_library_path(raw: str) -> str:
|
||||
"""경로 정규화. 엄격 정책 — 규칙 위반 시 ValueError 즉시 raise.
|
||||
|
||||
규칙:
|
||||
- 앞뒤 공백·슬래시 제거
|
||||
- segment별 trim
|
||||
- 빈 segment(// 또는 공백만) → ValueError
|
||||
- segment 30자 초과 → ValueError
|
||||
- 5단계 초과 → ValueError
|
||||
|
||||
GET /documents/library?path= 쿼리에도 동일하게 적용.
|
||||
"""
|
||||
stripped = raw.strip().strip("/")
|
||||
if not stripped:
|
||||
raise ValueError("빈 경로")
|
||||
|
||||
segments = stripped.split("/")
|
||||
normalized: list[str] = []
|
||||
for s in segments:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
raise ValueError("빈 세그먼트 (// 또는 공백만 있는 구간)")
|
||||
if len(s) > MAX_SEGMENT_LEN:
|
||||
raise ValueError(f"세그먼트 '{s}'가 {MAX_SEGMENT_LEN}자 초과")
|
||||
normalized.append(s)
|
||||
|
||||
if len(normalized) > MAX_DEPTH:
|
||||
raise ValueError(f"최대 {MAX_DEPTH}단계까지 가능")
|
||||
|
||||
return "/".join(normalized)
|
||||
|
||||
|
||||
def extract_library_paths(user_tags: list[str] | None) -> list[str]:
|
||||
"""user_tags에서 @library/ 경로만 추출 (prefix 포함)."""
|
||||
if not user_tags:
|
||||
return []
|
||||
return [t for t in user_tags if t.startswith(LIBRARY_PREFIX)]
|
||||
|
||||
|
||||
def validate_user_tags(tags: list) -> list[str]:
|
||||
"""user_tags 전체 검증. 입력 순서 보존, 중복 제거.
|
||||
|
||||
- 문자열이 아닌 원소 → TypeError
|
||||
- 빈 문자열 / 공백만 있는 태그 → 제거
|
||||
- 일반 태그 → strip() 후 통과
|
||||
- @library/ 태그 → normalize_library_path() 적용
|
||||
- 중복 → 첫 출현만 유지 (입력 순서 보존)
|
||||
"""
|
||||
result: list[str] = []
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str):
|
||||
raise TypeError(f"태그는 문자열이어야 합니다: {tag!r}")
|
||||
tag = tag.strip()
|
||||
if not tag:
|
||||
continue
|
||||
if tag.startswith(LIBRARY_PREFIX):
|
||||
path = tag[len(LIBRARY_PREFIX):]
|
||||
normalized = normalize_library_path(path)
|
||||
tag = f"{LIBRARY_PREFIX}{normalized}"
|
||||
result.append(tag)
|
||||
|
||||
# 중복 제거 (입력 순서 보존)
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for t in result:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
deduped.append(t)
|
||||
return deduped
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
|
||||
|
||||
let tree = $state([]);
|
||||
let loading = $state(true);
|
||||
@@ -188,6 +188,13 @@
|
||||
>
|
||||
<Mail size={14} /> 이메일
|
||||
</button>
|
||||
<a
|
||||
href="/library"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname === '/library' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
<FolderTree size={14} /> 자료실
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 메모 & Inbox -->
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script>
|
||||
// 자료실 경로 편집기 — user_tags에서 @library/ 접두사 관리
|
||||
import { onMount } from 'svelte';
|
||||
import { X, Plus, FolderPlus } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
|
||||
let { doc } = $props();
|
||||
|
||||
const PREFIX = '@library/';
|
||||
|
||||
let libraryPaths = $derived(
|
||||
(doc.user_tags || [])
|
||||
.filter((t) => t.startsWith(PREFIX))
|
||||
.map((t) => t.slice(PREFIX.length))
|
||||
);
|
||||
|
||||
let newPath = $state('');
|
||||
let editing = $state(false);
|
||||
let existingPaths = $state([]);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
let suggestions = $derived(
|
||||
newPath.length >= 1
|
||||
? existingPaths
|
||||
.filter(
|
||||
(p) => p.toLowerCase().includes(newPath.toLowerCase()) && !libraryPaths.includes(p)
|
||||
)
|
||||
.slice(0, 5)
|
||||
: []
|
||||
);
|
||||
|
||||
// 입력 정규화 미리보기
|
||||
let normalizedPreview = $derived(() => {
|
||||
try {
|
||||
return normalizePath(newPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (doc) {
|
||||
newPath = '';
|
||||
editing = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const tree = await api('/documents/library-tree');
|
||||
existingPaths = flattenPaths(tree);
|
||||
} 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('빈 경로');
|
||||
const segments = stripped.split('/');
|
||||
const normalized = [];
|
||||
for (const s of segments) {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) throw new Error('빈 세그먼트');
|
||||
if (trimmed.length > 30) throw new Error('세그먼트 30자 초과');
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
if (normalized.length > 5) throw new Error('최대 5단계');
|
||||
return normalized.join('/');
|
||||
}
|
||||
|
||||
async function addPath(rawPath) {
|
||||
let path;
|
||||
try {
|
||||
path = normalizePath(rawPath);
|
||||
} catch (e) {
|
||||
addToast('error', e.message);
|
||||
return;
|
||||
}
|
||||
const tag = `${PREFIX}${path}`;
|
||||
if ((doc.user_tags || []).includes(tag)) {
|
||||
addToast('warning', '이미 등록된 경로');
|
||||
return;
|
||||
}
|
||||
const updated = [...(doc.user_tags || []), tag];
|
||||
try {
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ user_tags: updated }),
|
||||
});
|
||||
doc.user_tags = updated;
|
||||
newPath = '';
|
||||
addToast('success', '자료실 경로 추가됨');
|
||||
} catch {
|
||||
addToast('error', '경로 추가 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePath(pathToRemove) {
|
||||
const tag = `${PREFIX}${pathToRemove}`;
|
||||
const updated = (doc.user_tags || []).filter((t) => t !== tag);
|
||||
try {
|
||||
await api(`/documents/${doc.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ user_tags: updated }),
|
||||
});
|
||||
doc.user_tags = updated;
|
||||
addToast('success', '경로 삭제됨');
|
||||
} catch {
|
||||
addToast('error', '경로 삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function selectSuggestion(path) {
|
||||
addPath(path);
|
||||
showSuggestions = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5 flex items-center gap-1">
|
||||
<FolderPlus size={12} />
|
||||
자료실 경로
|
||||
</h4>
|
||||
|
||||
<!-- 현재 경로 목록 -->
|
||||
{#if libraryPaths.length > 0}
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
{#each libraryPaths as path}
|
||||
<div
|
||||
class="flex items-center justify-between gap-1 px-2 py-1 bg-bg border border-default rounded text-xs"
|
||||
>
|
||||
<span class="text-text truncate" title={path}>{path}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePath(path)}
|
||||
class="text-dim hover:text-error shrink-0"
|
||||
aria-label="{path} 삭제"
|
||||
title="삭제"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 입력 영역 -->
|
||||
{#if editing}
|
||||
<div class="relative">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
addPath(newPath);
|
||||
}}
|
||||
class="flex gap-1"
|
||||
>
|
||||
<input
|
||||
bind:value={newPath}
|
||||
onfocus={() => (showSuggestions = true)}
|
||||
onblur={() => setTimeout(() => (showSuggestions = false), 200)}
|
||||
aria-label="자료실 경로"
|
||||
placeholder="회사A/양식/안전"
|
||||
class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 자동완성 드롭다운 -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<div
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-surface border border-default rounded shadow-lg z-dropdown"
|
||||
>
|
||||
{#each suggestions as suggestion}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={() => selectSuggestion(suggestion)}
|
||||
class="w-full text-left px-2 py-1.5 text-xs text-dim hover:bg-surface-hover hover:text-text"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 정규화 미리보기 -->
|
||||
{#if newPath.trim() && normalizedPreview() && normalizedPreview() !== newPath.trim()}
|
||||
<p class="text-[10px] text-dim mt-0.5">→ {normalizedPreview()}</p>
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -82,7 +82,9 @@
|
||||
{/if}
|
||||
<a href="/" class="text-sm font-semibold hover:text-accent">PKM</a>
|
||||
<span class="text-dim text-xs">/</span>
|
||||
<a href="/documents" class="text-xs hover:text-accent">문서</a>
|
||||
<a href="/documents" class="text-xs hover:text-accent {isActive('/documents') ? 'text-accent' : ''}">문서</a>
|
||||
<span class="text-faint text-xs">·</span>
|
||||
<a href="/library" class="text-xs hover:text-accent {isActive('/library') ? 'text-accent' : ''}">자료실</a>
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
@@ -80,14 +81,12 @@
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const url = `/api/documents/${docId}/file?token=${getAccessToken()}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc?.title || `document-${docId}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
function downloadOriginal() {
|
||||
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
@@ -141,9 +140,14 @@
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadFile}>
|
||||
다운로드
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
|
||||
원본 다운로드
|
||||
</Button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
@@ -225,6 +229,9 @@
|
||||
|
||||
<!-- 오른쪽 (1/3) — editors stack -->
|
||||
<aside class="space-y-4">
|
||||
<Card>
|
||||
<LibraryPathEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<NoteEditor {doc} />
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<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 {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
Download,
|
||||
FileText,
|
||||
} 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 Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
|
||||
// ─── 상태 ───
|
||||
|
||||
let tree = $state([]);
|
||||
let treeLoading = $state(true);
|
||||
let expanded = $state({});
|
||||
|
||||
let docs = $state([]);
|
||||
let docsLoading = $state(false);
|
||||
let total = $state(0);
|
||||
|
||||
let activePath = $derived($page.url.searchParams.get('path'));
|
||||
let activeSort = $derived($page.url.searchParams.get('sort') || 'updated_desc');
|
||||
let activeQ = $derived($page.url.searchParams.get('q') || '');
|
||||
let activePage = $derived(Number($page.url.searchParams.get('page')) || 1);
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'updated_desc', label: '최근 수정순' },
|
||||
{ value: 'title_asc', label: '제목순' },
|
||||
{ value: 'created_desc', label: '등록순' },
|
||||
];
|
||||
|
||||
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
async function loadTree() {
|
||||
treeLoading = true;
|
||||
try {
|
||||
tree = await api('/documents/library-tree');
|
||||
} catch {
|
||||
addToast('error', '자료실 트리 로딩 실패');
|
||||
} finally {
|
||||
treeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
docsLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (activePath) params.set('path', activePath);
|
||||
if (activeSort) params.set('sort', activeSort);
|
||||
if (activeQ) params.set('q', activeQ);
|
||||
params.set('page', String(activePage));
|
||||
params.set('page_size', '20');
|
||||
const result = await api(`/documents/library?${params}`);
|
||||
docs = result.items;
|
||||
total = result.total;
|
||||
} catch {
|
||||
addToast('error', '자료실 문서 로딩 실패');
|
||||
} finally {
|
||||
docsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTree();
|
||||
});
|
||||
|
||||
// URL 파라미터 변경 시 문서 목록 재로드
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
activePath, activeSort, activeQ, activePage;
|
||||
loadDocs();
|
||||
});
|
||||
|
||||
// 선택된 경로의 부모 자동 펼치기
|
||||
$effect(() => {
|
||||
if (activePath) {
|
||||
const parts = activePath.split('/');
|
||||
let path = '';
|
||||
for (const part of parts) {
|
||||
path = path ? `${path}/${part}` : part;
|
||||
expanded[path] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let totalCount = $derived(tree.reduce((sum, n) => sum + n.count, 0));
|
||||
|
||||
// ─── 네비게이션 ───
|
||||
|
||||
function navigate(path) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.delete('page');
|
||||
if (path) {
|
||||
params.set('path', path);
|
||||
} else {
|
||||
params.delete('path');
|
||||
}
|
||||
const qs = params.toString();
|
||||
goto(`/library${qs ? '?' + qs : ''}`, { noScroll: true });
|
||||
}
|
||||
|
||||
function setSort(sort) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.set('sort', sort);
|
||||
params.delete('page');
|
||||
goto(`/library?${params}`, { noScroll: true });
|
||||
}
|
||||
|
||||
function setSearch(q) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
if (q) {
|
||||
params.set('q', q);
|
||||
} else {
|
||||
params.delete('q');
|
||||
}
|
||||
params.delete('page');
|
||||
goto(`/library?${params}`, { noScroll: true });
|
||||
}
|
||||
|
||||
function toggleExpand(path) {
|
||||
expanded[path] = !expanded[path];
|
||||
}
|
||||
|
||||
// ─── 문서 액션 ───
|
||||
|
||||
function getEditUrl(doc) {
|
||||
if (doc.edit_url) return doc.edit_url;
|
||||
if (ODF_FORMATS.includes(doc.file_format)) return 'https://link.hyungi.net';
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadOriginal(doc) {
|
||||
window.open(`/api/documents/${doc.id}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf(doc) {
|
||||
window.open(`/api/documents/${doc.id}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
// ─── 검색 debounce ───
|
||||
|
||||
let searchInput = $state(activeQ);
|
||||
let searchTimer;
|
||||
|
||||
function handleSearchInput(e) {
|
||||
searchInput = e.target.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => setSearch(searchInput), 300);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
let totalPages = $derived(Math.ceil(total / 20));
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
<span class="text-faint">/</span>
|
||||
<span class="text-text">자료실</span>
|
||||
{#if activePath}
|
||||
{#each activePath.split('/') as segment, i}
|
||||
<span class="text-faint">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigate(activePath.split('/').slice(0, i + 1).join('/'))}
|
||||
class="hover:text-text"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<!-- 왼쪽: 트리 (5/12) -->
|
||||
<aside class="lg:col-span-5 xl:col-span-4">
|
||||
<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>
|
||||
|
||||
<!-- 트리 -->
|
||||
{#if treeLoading}
|
||||
{#each Array(3) as _}
|
||||
<div class="h-8 bg-bg rounded-md animate-pulse mx-1 mb-1"></div>
|
||||
{/each}
|
||||
{:else if tree.length === 0}
|
||||
<p class="text-xs text-dim px-3 py-4">
|
||||
아직 자료실에 등록된 문서가 없습니다.<br />
|
||||
문서 상세 페이지에서 자료실 경로를 추가하세요.
|
||||
</p>
|
||||
{:else}
|
||||
<nav>
|
||||
{#each tree as node}
|
||||
{#snippet treeNode(n, depth)}
|
||||
{@const isActive = activePath === n.path}
|
||||
{@const isParent = activePath?.startsWith(n.path + '/')}
|
||||
{@const hasChildren = n.children.length > 0}
|
||||
{@const isExpanded = expanded[n.path]}
|
||||
|
||||
<div class="flex items-center" style="padding-left: {depth * 16}px">
|
||||
{#if hasChildren}
|
||||
<button
|
||||
onclick={() => toggleExpand(n.path)}
|
||||
class="p-0.5 rounded hover:bg-surface-hover text-dim"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown size={14} />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="w-5"></span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => navigate(n.path)}
|
||||
class="flex-1 flex items-center justify-between px-2 py-1.5 rounded-md text-sm transition-colors
|
||||
{isActive
|
||||
? 'bg-accent/15 text-accent'
|
||||
: isParent
|
||||
? 'text-text'
|
||||
: 'text-dim hover:bg-surface-hover hover:text-text'}"
|
||||
>
|
||||
<span class="truncate">{n.name}</span>
|
||||
<span class="text-xs text-dim shrink-0 ml-2">{n.count}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasChildren && isExpanded}
|
||||
{#each n.children as child}
|
||||
{@render treeNode(child, depth + 1)}
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{@render treeNode(node, 0)}
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 오른쪽: 문서 목록 (7/12) -->
|
||||
<main class="lg:col-span-7 xl:col-span-8">
|
||||
<!-- 컨트롤 바 -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<!-- 정렬 -->
|
||||
<select
|
||||
value={activeSort}
|
||||
onchange={(e) => setSort(e.target.value)}
|
||||
class="px-2 py-1.5 bg-surface border border-default rounded text-xs text-text outline-none focus:border-accent"
|
||||
>
|
||||
{#each SORT_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- 제목 검색 -->
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
placeholder="제목 검색..."
|
||||
class="flex-1 min-w-[120px] max-w-[240px] px-2 py-1.5 bg-surface border border-default rounded text-xs text-text outline-none focus:border-accent"
|
||||
/>
|
||||
|
||||
<span class="text-xs text-dim ml-auto">{total}건</span>
|
||||
</div>
|
||||
|
||||
<!-- 문서 목록 -->
|
||||
{#if docsLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<Skeleton h="h-20" rounded="lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if docs.length === 0}
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={activePath ? `"${activePath}" 경로에 문서가 없습니다` : '자료실이 비어 있습니다'}
|
||||
description="문서 상세 페이지에서 자료실 경로를 추가하세요."
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each docs as doc (doc.id)}
|
||||
<div class="bg-surface border border-default rounded-lg hover:border-accent/50 transition-colors">
|
||||
<!-- 카드 상단: 문서 정보 -->
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="flex items-start gap-3 p-3"
|
||||
>
|
||||
<div class="shrink-0 mt-0.5 text-dim">
|
||||
<FormatIcon format={doc.file_format} size={18} />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate hover:text-accent">{doc.title || '제목 없음'}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-[11px] text-dim">
|
||||
<span>{doc.file_format}</span>
|
||||
{#if doc.updated_at}
|
||||
<span class="text-faint">·</span>
|
||||
<span>
|
||||
{new Date(doc.updated_at).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 카드 하단: 액션 버튼 -->
|
||||
<div class="flex items-center gap-1.5 px-3 pb-2">
|
||||
{#if getEditUrl(doc)}
|
||||
<a
|
||||
href={getEditUrl(doc)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-dim hover:text-accent hover:bg-surface-hover rounded transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} /> 편집
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => downloadOriginal(doc)}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-dim hover:text-accent hover:bg-surface-hover rounded transition-colors"
|
||||
>
|
||||
<Download size={12} /> 원본
|
||||
</button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => downloadPdf(doc)}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-dim hover:text-accent hover:bg-surface-hover rounded transition-colors"
|
||||
>
|
||||
<FileText size={12} /> PDF
|
||||
</button>
|
||||
{:else if doc.preview_status === 'processing'}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-faint" title="미리보기 생성 중">
|
||||
<FileText size={12} /> PDF 생성 중
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-6">
|
||||
{#if activePage > 1}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
const p = new URLSearchParams($page.url.searchParams);
|
||||
p.set('page', String(activePage - 1));
|
||||
goto(`/library?${p}`, { noScroll: true });
|
||||
}}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
{/if}
|
||||
<span class="text-xs text-dim">{activePage} / {totalPages}</span>
|
||||
{#if activePage < totalPages}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
const p = new URLSearchParams($page.url.searchParams);
|
||||
p.set('page', String(activePage + 1));
|
||||
goto(`/library?${p}`, { noScroll: true });
|
||||
}}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 자료실: user_tags JSONB GIN 인덱스 (경로 containment 쿼리 가속)
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user_tags_gin
|
||||
ON documents USING GIN (user_tags jsonb_path_ops)
|
||||
WHERE user_tags IS NOT NULL;
|
||||
Reference in New Issue
Block a user