feat(study): 복습함(B4 v1) — 오늘 할 일/미확인 2탭 + 멀티셀렉트 선택 복습
/study/review-box: GET /study-cards/due(review_stage) 를 2탭 분리(오늘 할 일=review_stage 보유 / 미확인=review_stage null 신규). 카드 멀티셀렉트 → pendingReviewCards store 로 cards-study 복습 세션에 선택분 전달(백엔드 세션 X = eid contention 중 fastapi 무재빌드). '이 탭 전체 복습'도. 완료 탭은 졸업카드 엔드포인트 필요라 비활성('추후'). 허브에 복습함 진입 카드.
- 신규 store /stores/studySession.ts(pendingReviewCards). cards-study startReview 가 consume. 전부 frontend-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 카드 학습 세션 전달용 store.
|
||||
*
|
||||
* 복습함(/study/review-box)에서 선택한 카드들을 cards-study 복습 세션으로 넘긴다.
|
||||
* 백엔드 '세션 by card_ids' 엔드포인트 없이(= eid contention 중 fastapi 무재빌드) 동작하도록
|
||||
* 선택 카드 객체 배열을 그대로 전달. cards-study 가 startReview 에서 consume(읽고 비움).
|
||||
*
|
||||
* 모듈 레벨 store 라 SPA 네비게이션 동안 유지되고, 새로고침 시 사라진다(그땐 복습함에서 다시 선택).
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// CardItem[] | null — 복습함에서 '선택 복습' 시 set, cards-study 가 소비 후 null.
|
||||
export const pendingReviewCards = writable(null);
|
||||
@@ -3,7 +3,7 @@
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag } from 'lucide-svelte';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
@@ -86,6 +86,17 @@
|
||||
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 가볍게 훑어봅니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/review-box"
|
||||
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">
|
||||
<Inbox size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">복습함</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">오늘 복습할 카드와 미확인 카드를 한눈에 보고, <b>골라서</b> 복습합니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/questions-review"
|
||||
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import { get } from 'svelte/store';
|
||||
import { pendingReviewCards } from '$lib/stores/studySession';
|
||||
|
||||
// sr_schedule.py 단일 source 미러 — '암'(correct) 다음 복습일 라벨 전용(실제 스케줄은 백엔드).
|
||||
// stage===null = 신규 카드(progress 없음): '암'이면 백엔드가 due 안 박음(외움→큐 제외)이라 '안 나옴'.
|
||||
@@ -82,6 +84,14 @@
|
||||
revealed = false;
|
||||
tally = { correct: 0, unsure: 0, wrong: 0 };
|
||||
marks = [];
|
||||
// 복습함(/study/review-box)에서 선택해 넘긴 카드가 있으면 그걸로 세션 구성.
|
||||
const preset = get(pendingReviewCards);
|
||||
if (preset && preset.length) {
|
||||
pendingReviewCards.set(null); // 소비
|
||||
cards = preset;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cards = _dueCache ?? (await fetchDue());
|
||||
_dueCache = null; // 소비
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/review-box — 복습함 (카드 SR 복습 현황 + 선택 학습, B4).
|
||||
*
|
||||
* GET /study-cards/due (review_stage 포함) 로 오늘의 복습 큐를 받아 2탭으로 분리:
|
||||
* - 오늘 할 일: review_stage != null (예전에 평가돼 복습일이 도래한 카드)
|
||||
* - 미확인 : review_stage == null (검수 통과했지만 아직 한 번도 회상 안 한 새 카드)
|
||||
* - 완료 : 졸업 카드 — 백엔드 엔드포인트 필요(현재 미배포 = eid contention 중 fastapi 무재빌드)라 추후.
|
||||
*
|
||||
* 멀티셀렉트 → 선택 카드를 pendingReviewCards store 로 cards-study 복습 세션에 전달(백엔드 세션 X).
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { pendingReviewCards } from '$lib/stores/studySession';
|
||||
import { ArrowLeft, Repeat, GraduationCap, CheckCheck, Play } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let cards = $state([]); // /due 결과 (CardItem[], review_stage 포함)
|
||||
let tab = $state('today'); // 'today' | 'new' | 'done'
|
||||
let selected = $state({}); // card.id -> true
|
||||
|
||||
let newCards = $derived(cards.filter((c) => c.review_stage === null || c.review_stage === undefined));
|
||||
let dueCards = $derived(cards.filter((c) => c.review_stage !== null && c.review_stage !== undefined));
|
||||
let shown = $derived(tab === 'today' ? dueCards : tab === 'new' ? newCards : []);
|
||||
let selectedCount = $derived(shown.filter((c) => selected[c.id]).length);
|
||||
let allShownSelected = $derived(shown.length > 0 && shown.every((c) => selected[c.id]));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
cards = (await api('/study-cards/due?limit=200')) ?? [];
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '복습 카드 조회 실패');
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function frontText(c) {
|
||||
const t = (c.format === 'cloze' && c.cloze_text ? c.cloze_text : c.cue) ?? '';
|
||||
return t.length > 60 ? t.slice(0, 60) + '…' : t;
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
selected = { ...selected, [id]: !selected[id] };
|
||||
}
|
||||
function selectAllShown() {
|
||||
const next = { ...selected };
|
||||
shown.forEach((c) => { next[c.id] = !allShownSelected; });
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function startCards(list) {
|
||||
if (!list.length) return;
|
||||
pendingReviewCards.set(list);
|
||||
goto('/study/cards-study?mode=review');
|
||||
}
|
||||
function startSelected() {
|
||||
startCards(shown.filter((c) => selected[c.id]));
|
||||
}
|
||||
function startTab() {
|
||||
startCards(shown);
|
||||
}
|
||||
|
||||
function setTab(t) {
|
||||
if (t === 'done') return; // 완료 탭은 백엔드 준비 전 비활성
|
||||
tab = t;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>복습함</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
|
||||
<h1 class="text-xl font-bold text-text">복습함</h1>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-dim">
|
||||
검수 통과한 암기카드의 복습 현황입니다. 탭에서 카드를 골라 <b class="text-text">선택 복습</b>하거나, 탭 전체를 한 번에 복습할 수 있어요.
|
||||
</p>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="mb-4 flex gap-1.5">
|
||||
{#each [['today', '오늘 할 일', dueCards.length], ['new', '미확인', newCards.length], ['done', '완료', null]] as [val, label, n] (val)}
|
||||
<button
|
||||
onclick={() => setTab(val)}
|
||||
disabled={val === 'done'}
|
||||
class="flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors
|
||||
{tab === val ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim hover:text-text'}
|
||||
{val === 'done' ? 'cursor-not-allowed opacity-50' : ''}"
|
||||
>
|
||||
{label}
|
||||
{#if n !== null}<span class="rounded-full px-1.5 text-[10px] {tab === val ? 'bg-white/25' : 'bg-default'}">{n}</span>{/if}
|
||||
{#if val === 'done'}<span class="text-[10px]">추후</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">{#each Array(5).fill(0) as _, i (i)}<Skeleton class="h-12 w-full" />{/each}</div>
|
||||
{:else if tab === 'done'}
|
||||
<EmptyState title="완료 탭은 준비 중" description="졸업(완료)한 카드 목록은 백엔드 엔드포인트가 준비되면 추가됩니다." icon={GraduationCap} />
|
||||
{:else if shown.length === 0}
|
||||
<EmptyState
|
||||
title={tab === 'today' ? '오늘 복습할 카드가 없습니다' : '미확인 카드가 없습니다'}
|
||||
description={tab === 'today' ? '애매·모름으로 평가한 카드의 복습일이 되면 여기에 나타납니다.' : '검수 통과한 새 카드가 여기에 모입니다. 지금은 모두 한 번씩 본 상태예요.'}
|
||||
icon={Repeat}
|
||||
/>
|
||||
{:else}
|
||||
<!-- 선택 바 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button onclick={selectAllShown} class="rounded-md border border-default px-2.5 py-1 text-xs font-medium text-dim transition-colors hover:text-text">
|
||||
{allShownSelected ? '선택 해제' : '전체 선택'}
|
||||
</button>
|
||||
<span class="text-xs text-dim">{selectedCount > 0 ? `${selectedCount}장 선택됨` : `${shown.length}장`}</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="secondary" size="sm" icon={Play} onclick={startSelected}>선택 {selectedCount}장 복습</Button>
|
||||
{/if}
|
||||
<Button variant="primary" size="sm" icon={CheckCheck} onclick={startTab}>이 탭 전체 복습</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카드 목록 -->
|
||||
<div class="space-y-1.5">
|
||||
{#each shown as c (c.id)}
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-default bg-surface px-3 py-2.5 transition-colors hover:border-accent">
|
||||
<input type="checkbox" checked={!!selected[c.id]} onchange={() => toggle(c.id)} class="size-4 shrink-0 accent-accent" />
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-white {c.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}">{c.format}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{frontText(c)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user