feat(study): /study/sources 학습 hub 신설 — 자료 학습 페이지

기존 /library 의 회독 UI 를 학습 hub 로 분리. 학습 의도 인터페이스를
공부(/study) 트랙에 모아 자료실(library) 의 일반 자료 관리와 분리.

신규:
- /study (hub): "자료 학습" / "손글씨 필사 세션" 두 카드 메뉴.
  Phase 2~ 예정 항목 (모바일 카드 / 퀴즈 / SRS) 안내.
  기존 /study → /study/write 자동 redirect 제거.
- /study/sources (자료 학습):
  · 좌측 트리: /api/library/tree 활용. 노드별 회독 안 본 카운트
    (예: "3 / 12") 표시. 활성 경로 자동 펼치기.
  · 우측 본문: /api/documents/library 활용 (path/sort/unread/page).
    DocumentCard 재사용 — 회독 배지 (안 봄/N회독) 그대로 노출.
  · 안 본 자료만 토글 + 정렬 선택 + 페이지네이션.
  · 자료실 관리 기능 (CRUD/업로드/facet/승인 대기) 제외 — 순수 학습 UI.

backend 변경 없음. PR-A 의 /api/documents/{id}/read* 와 library API 응답
read_count/unread_count 그대로 활용.

기존 /library 페이지의 회독 UI (배지/토글/ReadCounter) 는 일관성 위해 유지.
자료를 어디서 들어가든 회독 가능 (자료실 자료 detail 의 ReadCounter 도 그대로).
This commit is contained in:
Hyungi Ahn
2026-04-27 12:25:29 +09:00
parent 9b20a1815f
commit a428b2e679
2 changed files with 338 additions and 10 deletions
+44 -10
View File
@@ -1,13 +1,47 @@
<script>
// /study — Phase 1 진입점.
// Phase 3 부터는 디바이스 분기 (iPad → /study/write, 모바일 → /study/review) 추가 예정.
// Phase 1 에선 단순히 /study/write 로 navigate.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
onMount(() => {
goto('/study/write', { replaceState: true });
});
// /study — 학습 hub.
// 자료 학습 (자료실 자료 + 회독 추적) / 필사 세션 (Apple Pencil) / Phase 2~ 퀴즈/SRS.
import { BookOpen, PenLine, GraduationCap } from 'lucide-svelte';
</script>
<div class="p-6 text-sm text-dim">학습 페이지로 이동 중...</div>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<header class="mb-6">
<h1 class="text-xl font-semibold text-text flex items-center gap-2">
<GraduationCap size={22} /> 공부
</h1>
<p class="text-sm text-dim mt-1">학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.</p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="/study/sources"
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex items-center gap-2 mb-2">
<BookOpen size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">자료 학습</h2>
</div>
<p class="text-xs text-dim">자료실에 올린 학습 자료를 카테고리별로 읽고 회독 카운트를 누적합니다. "안 본 자료만" 필터로 진도 관리.</p>
</a>
<a
href="/study/write"
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex items-center gap-2 mb-2">
<PenLine size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">손글씨 필사 세션</h2>
</div>
<p class="text-xs text-dim">iPad + Apple Pencil 로 자격증 교재 / 어학 한자·단어를 손으로 필사. 세션 단위 stroke 보존.</p>
</a>
</div>
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
<div class="font-medium text-dim mb-1">예정 (Phase 2~)</div>
<ul class="list-disc list-inside space-y-0.5">
<li>모바일 암기노트 / 카드 복습</li>
<li>AI 자료 기반 퀴즈 출제 + 정답률 분야별 통계</li>
<li>SRS (1·3·7·14일 복습 일정)</li>
</ul>
</div>
</div>
@@ -0,0 +1,294 @@
<script>
/**
* /study/sources — 자료 학습 hub.
*
* 자료실(/library) 의 자료를 학습용 인터페이스로 노출:
* - 좌측: 카테고리 트리 (회독 안 본 카운트 포함)
* - 우측: 자료 카드 목록 (회독 배지 + 클릭 시 detail 진입)
* - 상단 toggle: "안 본 자료만"
*
* 자료실 페이지(/library)의 자료 관리 (CRUD/업로드/facet/승인 대기) 기능은 제외.
* 순수 학습 인터페이스. 자료 추가는 /library 에서 진행.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ChevronRight, ChevronDown, FolderOpen, BookOpen, ArrowLeft,
} from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.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 activeUnread = $derived($page.url.searchParams.get('unread') === 'true');
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: '등록순' },
];
// ─── 데이터 로드 ───
async function loadTree() {
treeLoading = true;
try {
tree = await api('/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 (activeUnread) params.set('unread', 'true');
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);
$effect(() => {
void activePath; void activeSort; void activeUnread; void activePage;
loadDocs();
});
// 활성 경로의 부모 자동 펼치기
$effect(() => {
if (activePath) {
const parts = activePath.split('/');
let p = '';
for (const part of parts) {
p = p ? `${p}/${part}` : part;
expanded[p] = true;
}
}
});
// 회독·진도 통계 (트리 전체 합산)
let totalCount = $derived(tree.reduce((s, n) => s + n.count, 0));
let totalUnread = $derived(tree.reduce((s, n) => s + (n.unread_count ?? 0), 0));
// ─── 네비게이션 ───
function navigate(path) {
const params = new URLSearchParams($page.url.searchParams);
params.delete('page');
if (path) params.set('path', path); else params.delete('path');
goto(`/study/sources?${params}`, { noScroll: true });
}
function setSort(sort) {
const params = new URLSearchParams($page.url.searchParams);
params.set('sort', sort);
params.delete('page');
goto(`/study/sources?${params}`, { noScroll: true });
}
function toggleUnread() {
const params = new URLSearchParams($page.url.searchParams);
if (activeUnread) params.delete('unread');
else params.set('unread', 'true');
params.delete('page');
goto(`/study/sources?${params}`, { noScroll: true });
}
function setPage(p) {
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(p));
goto(`/study/sources?${params}`, { noScroll: true });
}
function toggleExpand(path) {
expanded[path] = !expanded[path];
}
</script>
<svelte:head><title>자료 학습 — 공부</title></svelte:head>
<div class="h-full flex flex-col">
<!-- 헤더 -->
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-default bg-surface shrink-0">
<div class="flex items-center gap-2 text-sm">
<a href="/study" class="text-dim hover:text-text flex items-center gap-1">
<ArrowLeft size={14} /> 공부
</a>
<span class="text-faint">/</span>
<span class="text-text font-medium flex items-center gap-1.5">
<BookOpen size={14} class="text-accent" /> 자료 학습
</span>
</div>
<div class="text-xs text-dim">
{totalCount}건 · <span class="text-text">안 본 {totalUnread}</span>
</div>
</div>
<div class="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-[260px_1fr] gap-0">
<!-- 좌측 트리 -->
<aside class="border-r border-default overflow-y-auto p-2 hidden md:block">
<button
type="button"
onclick={() => navigate(null)}
class="w-full flex items-center justify-between gap-2 px-2 py-1.5 rounded text-sm transition-colors
{!activePath ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
>
<span class="flex items-center gap-2"><FolderOpen size={14} /> 전체 자료</span>
<span class="text-xs text-dim">{totalCount}</span>
</button>
{#if treeLoading}
<div class="space-y-1 mt-2">
{#each Array(4) as _}<Skeleton h="h-8" rounded="md" />{/each}
</div>
{:else}
{#snippet treeNode(n, depth)}
{@const isActive = activePath === n.path}
{@const isParent = activePath?.startsWith(n.path + '/')}
{@const hasChildren = (n.children?.length ?? 0) > 0}
{@const isExpanded = expanded[n.path]}
<div class="flex items-center" style="padding-left: {depth * 12}px">
{#if hasChildren}
<button
type="button"
onclick={() => toggleExpand(n.path)}
class="p-0.5 rounded hover:bg-surface text-dim shrink-0"
aria-label={isExpanded ? '접기' : '펼치기'}
>
{#if isExpanded}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
</button>
{:else}
<span class="w-4 shrink-0"></span>
{/if}
<button
type="button"
onclick={() => navigate(n.path)}
class="flex-1 flex items-center justify-between gap-2 px-2 py-1 rounded text-xs transition-colors text-left
{isActive ? 'bg-accent/15 text-accent' : isParent ? 'text-text' : 'text-dim hover:bg-surface hover:text-text'}"
aria-current={isActive ? 'page' : undefined}
>
<span class="truncate">{n.name}</span>
<span class="text-[10px] text-dim shrink-0">
{#if (n.unread_count ?? 0) > 0}
<span class="text-accent">{n.unread_count}</span> / {n.count}
{:else}
{n.count}
{/if}
</span>
</button>
</div>
{#if hasChildren && isExpanded}
{#each n.children as c}
{@render treeNode(c, depth + 1)}
{/each}
{/if}
{/snippet}
{#each tree as n}
{@render treeNode(n, 0)}
{/each}
{#if tree.length === 0}
<div class="text-xs text-dim p-3">자료실에 자료를 먼저 업로드하세요.</div>
{/if}
{/if}
</aside>
<!-- 우측 본문 -->
<section class="overflow-y-auto p-3 md:p-4">
<!-- 컨트롤 -->
<div class="flex items-center gap-2 mb-3 flex-wrap">
{#if activePath}
<span class="text-xs text-dim">
현재: <span class="text-text">{activePath}</span>
</span>
{/if}
<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>
<button
type="button"
onclick={toggleUnread}
style="touch-action: manipulation; user-select: none; -webkit-tap-highlight-color: transparent;"
class="px-2 py-1.5 rounded text-xs border transition-colors
{activeUnread ? 'bg-accent/15 text-accent border-accent/40' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={activeUnread}
>
{activeUnread ? '✓ 안 본 자료만' : '안 본 자료만'}
</button>
<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={BookOpen}
title={activeUnread ? '안 본 자료 없음' : '자료 없음'}
description={activeUnread ? '모든 자료를 1회 이상 보셨습니다.' : '자료실에 자료를 업로드하면 여기서 학습할 수 있습니다.'}
/>
{:else}
<div class="space-y-2">
{#each docs as doc (doc.id)}
<DocumentCard {doc} showDomain={false} />
{/each}
</div>
<!-- 페이지네이션 -->
{#if total > 20}
<div class="flex items-center justify-center gap-2 mt-4 text-xs">
<button
type="button"
disabled={activePage <= 1}
onclick={() => setPage(activePage - 1)}
class="px-3 py-1.5 rounded border border-default text-dim hover:text-text disabled:opacity-40"
>이전</button>
<span class="text-dim">{activePage} / {Math.ceil(total / 20)}</span>
<button
type="button"
disabled={activePage >= Math.ceil(total / 20)}
onclick={() => setPage(activePage + 1)}
class="px-3 py-1.5 rounded border border-default text-dim hover:text-text disabled:opacity-40"
>다음</button>
</div>
{/if}
{/if}
</section>
</div>
</div>