feat(library): 모바일 학습 detail 최적화 + 다음 자료 네비 (PR-E)

[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 바에 가리지 않음
This commit is contained in:
Hyungi Ahn
2026-04-27 12:41:43 +09:00
parent 24bd363beb
commit e92bf3c06b
2 changed files with 123 additions and 3 deletions
+57
View File
@@ -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,
@@ -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}
</div>
<!-- 뷰어 -->
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
<Card class="min-h-[500px]">
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
<div class="prose prose-invert prose-sm max-w-none markdown-body">
<div class="prose prose-invert prose-base lg:prose-sm max-w-none markdown-body leading-relaxed">
{@html renderMd(doc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
</div>
{:else if viewerType === 'pdf'}
@@ -332,5 +358,42 @@
</Card>
</aside>
</div>
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
{#if doc.category === 'library'}
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
<button
type="button"
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
disabled={!neighbors.prev}
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="이전 자료"
><ChevronLeft size={20} /></button>
<button
type="button"
onclick={readAndGoNext}
disabled={!neighbors.next}
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
>
<Check size={16} />
{#if neighbors.next}
1회독 완료 + 다음
{:else}
1회독 완료 (마지막 자료)
{/if}
</button>
<button
type="button"
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
disabled={!neighbors.next}
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="다음 자료 (회독 카운트 안 함)"
><ChevronRight size={20} /></button>
</div>
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
<div class="lg:hidden h-20"></div>
{/if}
{/if}
</div>