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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user