From 2c8b6808b91b731887e7ffef911c1835302d516b Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 16:17:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EB=B3=B5=EC=8A=B5=ED=95=A8(B4?= =?UTF-8?q?=20v1)=20=E2=80=94=20=EC=98=A4=EB=8A=98=20=ED=95=A0=20=EC=9D=BC?= =?UTF-8?q?/=EB=AF=B8=ED=99=95=EC=9D=B8=202=ED=83=AD=20+=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=EC=85=80=EB=A0=89=ED=8A=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B3=B5=EC=8A=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- frontend/src/lib/stores/studySession.ts | 13 ++ frontend/src/routes/study/+page.svelte | 13 +- .../src/routes/study/cards-study/+page.svelte | 10 ++ .../src/routes/study/review-box/+page.svelte | 143 ++++++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/stores/studySession.ts create mode 100644 frontend/src/routes/study/review-box/+page.svelte diff --git a/frontend/src/lib/stores/studySession.ts b/frontend/src/lib/stores/studySession.ts new file mode 100644 index 0000000..c1cda3b --- /dev/null +++ b/frontend/src/lib/stores/studySession.ts @@ -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); diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index ce26300..7346a59 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -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 @@

검수한 암기카드를 모바일에서 학습. 복습(간격반복 1·3·7·14일)으로 자기평가하거나, 그냥 공부로 덜 본 카드를 가볍게 훑어봅니다.

+ +
+ +

복습함

+
+

오늘 복습할 카드와 미확인 카드를 한눈에 보고, 골라서 복습합니다.

+
+ + /** + * /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); + + +복습함 + +
+
+ +

복습함

+
+

+ 검수 통과한 암기카드의 복습 현황입니다. 탭에서 카드를 골라 선택 복습하거나, 탭 전체를 한 번에 복습할 수 있어요. +

+ + +
+ {#each [['today', '오늘 할 일', dueCards.length], ['new', '미확인', newCards.length], ['done', '완료', null]] as [val, label, n] (val)} + + {/each} +
+ + {#if loading} +
{#each Array(5).fill(0) as _, i (i)}{/each}
+ {:else if tab === 'done'} + + {:else if shown.length === 0} + + {:else} + +
+ + {selectedCount > 0 ? `${selectedCount}장 선택됨` : `${shown.length}장`} +
+ {#if selectedCount > 0} + + {/if} + +
+
+ + +
+ {#each shown as c (c.id)} + + {/each} +
+ {/if} +