diff --git a/app/api/documents.py b/app/api/documents.py index d94fcbb..bb1905f 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -520,6 +520,63 @@ async def get_document( return DocumentResponse.model_validate(doc) +# ─── 자료실 인접 자료 (이전/다음) ─── +# 학습 흐름: 한 자료 다 읽으면 같은 챕터의 다음 자료로 자연스럽게 이동. +# library_path (정확 일치 + 하위 prefix) 안에서 title 오름차순 기준. +class NeighborItem(BaseModel): + id: int + title: str | None + + +class LibraryNeighborsResponse(BaseModel): + prev: NeighborItem | None + next: NeighborItem | None + path: str | None # 같은 path 내에서 계산된 결과 + + +@router.get("/{doc_id}/library-neighbors", response_model=LibraryNeighborsResponse) +async def get_library_neighbors( + doc_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """현재 자료의 같은 library_path 안에서 이전/다음 자료. title_asc 정렬 기준. + + library_path 추출: user_tags 의 첫 번째 `@library/...` 태그. + """ + from core.library import LIBRARY_PREFIX + + doc = await session.get(Document, doc_id) + if not doc or doc.deleted_at is not None or doc.category != "library": + raise HTTPException(status_code=404, detail="자료실 자료가 아닙니다") + + # 첫 번째 library 태그를 path 로 + path: str | None = None + for t in (doc.user_tags or []): + if isinstance(t, str) and t.startswith(LIBRARY_PREFIX): + path = t[len(LIBRARY_PREFIX):] + break + if not path: + return LibraryNeighborsResponse(prev=None, next=None, path=None) + + # 같은 path (정확히) 의 자료들 — title 오름차순 + exact_tag = f"{LIBRARY_PREFIX}{path}" + res = await session.execute( + select(Document.id, Document.title) + .where( + Document.deleted_at == None, # noqa: E711 + Document.category == "library", + Document.user_tags.op("@>")(f'["{exact_tag}"]'), + ) + .order_by(Document.title.asc().nullslast(), Document.id.asc()) + ) + rows = list(res) + idx = next((i for i, r in enumerate(rows) if r.id == doc_id), -1) + prev_n = NeighborItem(id=rows[idx - 1].id, title=rows[idx - 1].title) if idx > 0 else None + next_n = NeighborItem(id=rows[idx + 1].id, title=rows[idx + 1].title) if 0 <= idx < len(rows) - 1 else None + return LibraryNeighborsResponse(prev=prev_n, next=next_n, path=path) + + @router.get("/{doc_id}/file") async def get_document_file( doc_id: int, diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 2d47119..bcb8a69 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -9,7 +9,7 @@ import { addToast } from '$lib/stores/toast'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; - import { ExternalLink, Download, Link2, FileText, PenLine, X } from 'lucide-svelte'; + import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte'; @@ -72,6 +72,30 @@ noteOpen = !noteOpen; } + // 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션 + let neighbors = $state({ prev: null, next: null }); + async function loadNeighbors() { + try { + neighbors = await api(`/documents/${docId}/library-neighbors`); + } catch { + neighbors = { prev: null, next: null }; + } + } + + // "1회독 완료 + 다음 자료로" 한 번에 + async function readAndGoNext() { + try { + await api(`/documents/${docId}/read`, { method: 'POST' }); + addToast('success', '1회독 완료'); + } catch (err) { + addToast('error', err?.detail || '회독 기록 실패'); + return; + } + if (neighbors.next) { + goto(`/documents/${neighbors.next.id}`); + } + } + onMount(async () => { try { doc = await api(`/documents/${docId}`); @@ -89,6 +113,8 @@ } finally { loading = false; } + // 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비) + if (doc && doc.category === 'library') loadNeighbors(); }); let viewerType = $derived( @@ -195,10 +221,10 @@ {/if} - + {#if viewerType === 'markdown' || viewerType === 'hwp-markdown'} -
+
{@html renderMd(doc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
{:else if viewerType === 'pdf'} @@ -332,5 +358,42 @@
+ + + {#if doc.category === 'library'} +
+ + + + + +
+ +
+ {/if} {/if}