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