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:
Hyungi Ahn
2026-04-14 14:55:45 +09:00
parent 6067177913
commit deb5c1b704
8 changed files with 900 additions and 14 deletions
+159 -2
View File
@@ -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},
)
+79
View File
@@ -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
+8 -1
View File
@@ -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>
+3 -1
View File
@@ -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">
+17 -10
View File
@@ -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>
+414
View File
@@ -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>
+4
View File
@@ -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;