From e92bf3c06b74db26634fcc0c9de167e40ea496a4 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 12:41:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(library):=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20detail=20=EC=B5=9C=EC=A0=81=ED=99=94=20+?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=8C=20=EC=9E=90=EB=A3=8C=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=20(PR-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Backend] - /api/documents/{id}/library-neighbors — 같은 library_path 내 prev/next 자료 (title_asc 정렬). user_tags 의 첫 @library/* 태그를 path 로 사용. category='library' 만 응답. [Frontend] - routes/documents/[id]/+page.svelte: · 마크다운 본문: 모바일 prose-base (가독성), lg+ prose-sm 유지 + leading-relaxed · onMount 시 자료실 자료면 loadNeighbors 자동 호출 · 모바일 sticky 하단 바 (lg:hidden): [< 이전] [✓ 1회독 완료 + 다음 (primary)] [다음 →] - 가운데 버튼: POST /read 후 next 자료로 goto. 마지막 자료면 "1회독 완료 (마지막 자료)" 텍스트 + next 버튼 disabled. - 좌/우 버튼: 회독 카운트 안 함, 단순 이동 (이전 자료 / 회독 안 한 다음) · 본문 하단 패딩 (lg:hidden h-20) — sticky 바에 가리지 않음 --- app/api/documents.py | 57 +++++++++++++++ .../src/routes/documents/[id]/+page.svelte | 69 ++++++++++++++++++- 2 files changed, 123 insertions(+), 3 deletions(-) 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}