feat(study): 문제은행 + 복습모드 (study_questions)

study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.

데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
  SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
  RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)

설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
  부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)

API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.

프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).

후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 08:00:37 +09:00
parent efa1781211
commit 4b7156061e
15 changed files with 1678 additions and 6 deletions
@@ -220,9 +220,10 @@
{#if t.description}
<p class="text-xs text-dim line-clamp-2 mb-2">{t.description}</p>
{/if}
<div class="text-[10px] text-dim flex items-center gap-3">
<div class="text-[10px] text-dim flex items-center gap-3 flex-wrap">
<span>필기 <span class="text-text">{t.session_count}</span></span>
<span>자료 <span class="text-text">{t.document_count}</span></span>
<span>문제 <span class="text-text">{t.question_count ?? 0}</span></span>
<span class="ml-auto">{fmtDate(t.created_at)}</span>
</div>
</a>
@@ -12,7 +12,7 @@
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages,
ChevronRight, ChevronDown, FolderOpen, FolderPlus,
ChevronRight, ChevronDown, FolderOpen, FolderPlus, HelpCircle, Edit, Play, CheckCircle2, XCircle,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -365,6 +365,54 @@
</div>
{/if}
</section>
<!-- 문제 (PR-2) -->
<section class="mb-5">
<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
<h2 class="text-sm font-semibold text-text flex items-center gap-2">
<HelpCircle size={14} class="text-accent" /> 문제
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
</h2>
<div class="flex items-center gap-1">
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button>
{#if (detail.sections.questions?.length ?? 0) > 0}
<Button href={`/study/topics/${topicId}/review`} size="sm" icon={Play}>복습 시작</Button>
{/if}
</div>
</div>
{#if (detail.sections.questions?.length ?? 0) === 0}
<div class="text-xs text-dim p-3 border border-dashed border-default/60 rounded-lg">
이 주제에 입력된 문제가 없습니다. "새 문제" 로 4지선다 객관식을 추가하면 복습모드에서 무작위 출제됩니다.
</div>
{:else}
<div class="flex flex-col gap-2">
{#each detail.sections.questions as q (q.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="text-sm text-text line-clamp-2">{q.question_text}</div>
<div class="text-[11px] text-dim mt-1 flex items-center gap-2 flex-wrap">
{#if q.subject}<span>{q.subject}</span>{/if}
{#if q.scope}<span>· {q.scope}</span>{/if}
{#if q.exam_round}<span>· {q.exam_round}</span>{/if}
{#if q.attempt_count > 0}
<span class="ml-auto flex items-center gap-1">
{#if q.last_correct === true}
<CheckCircle2 size={11} class="text-success" />
{:else if q.last_correct === false}
<XCircle size={11} class="text-error" />
{/if}
<span>{q.attempt_count}</span>
</span>
{/if}
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</div>
</div>
<Button href={`/study/topics/${topicId}/questions/${q.id}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
@@ -0,0 +1,195 @@
<script>
/**
* /study/topics/[id]/questions/[qid]/edit — 문제 편집 페이지.
*
* 정답(correct_choice) 변경 시 기존 attempts.is_correct 는 재계산되지 않음.
* 풀이 시점의 정오답이 그대로 보존되므로 안내 배너 고정 노출.
* 삭제는 soft delete 만 (DB FK RESTRICT 로 hard delete 차단, 이력 보존).
*/
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 { ArrowLeft, Save, Trash2, AlertCircle } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
let topicId = $derived(Number($page.params.id));
let questionId = $derived(Number($page.params.qid));
let loading = $state(true);
let saving = $state(false);
let deleting = $state(false);
let topicName = $state('');
let q_text = $state('');
let c1 = $state(''); let c2 = $state(''); let c3 = $state(''); let c4 = $state('');
let correct = $state(1);
let subject = $state(''); let scope = $state('');
let exam_name = $state(''); let exam_round = $state('');
let explanation = $state(''); let source_note = $state('');
let is_active = $state(true);
let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 });
async function load() {
loading = true;
try {
const [t, q] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-questions/${questionId}`),
]);
topicName = t?.topic?.name ?? '';
q_text = q.question_text;
c1 = q.choice_1; c2 = q.choice_2; c3 = q.choice_3; c4 = q.choice_4;
correct = q.correct_choice;
subject = q.subject ?? '';
scope = q.scope ?? '';
exam_name = q.exam_name ?? '';
exam_round = q.exam_round ?? '';
explanation = q.explanation ?? '';
source_note = q.source_note ?? '';
is_active = q.is_active;
stats = q.stats;
} catch (err) {
addToast('error', err.detail || '문제 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
addToast('error', '1~4번 보기를 모두 입력하세요'); return false;
}
return true;
}
async function save() {
if (!validate()) return;
saving = true;
try {
await api(`/study-questions/${questionId}`, {
method: 'PATCH',
body: JSON.stringify({
question_text: q_text.trim(),
choice_1: c1.trim(), choice_2: c2.trim(),
choice_3: c3.trim(), choice_4: c4.trim(),
correct_choice: Number(correct),
subject: subject || null,
scope: scope || null,
exam_name: exam_name || null,
exam_round: exam_round || null,
explanation: explanation || null,
source_note: source_note || null,
is_active,
}),
});
addToast('success', '저장됨');
goto(`/study/topics/${topicId}`);
} catch (err) {
addToast('error', err.detail || '저장 실패');
} finally {
saving = false;
}
}
async function remove() {
if (!confirm('이 문제를 삭제합니다.\n풀이 기록(attempts)은 보존되지만 복습 후보에서는 제외됩니다.')) return;
deleting = true;
try {
await api(`/study-questions/${questionId}`, { method: 'DELETE' });
addToast('success', '삭제됨');
goto(`/study/topics/${topicId}`);
} catch (err) {
addToast('error', err.detail || '삭제 실패');
deleting = false;
}
}
</script>
<svelte:head><title>문제 편집 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">문제 편집</span>
</div>
<!-- 정답 변경 → attempts 재계산 안 됨 안내 -->
<div class="mb-3 p-3 rounded-lg border border-warning/40 bg-warning/5 text-xs text-text flex items-start gap-2">
<AlertCircle size={14} class="text-warning shrink-0 mt-0.5" />
<div>
정답을 수정해도 기존 풀이 기록은 변경되지 않습니다.<br />
풀이 시점의 정답·정오답이 그대로 보존됩니다 ({stats.attempt_count}회 누적, 정답 {stats.correct_count} / 오답 {stats.wrong_count}).
</div>
</div>
{#if loading}
<Skeleton h="h-64" rounded="lg" />
{:else}
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<Textarea label="문제 본문" bind:value={q_text} rows={3} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} />
<TextInput label="② 2번 보기" bind:value={c2} />
<TextInput label="③ 3번 보기" bind:value={c3} />
<TextInput label="④ 4번 보기" bind:value={c4} />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n}
<button
type="button"
onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n}
>{n}</button>
{/each}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default">
<TextInput label="과목" bind:value={subject} />
<TextInput label="범위" bind:value={scope} />
<TextInput label="시험명" bind:value={exam_name} />
<TextInput label="회차" bind:value={exam_round} />
</div>
<Textarea label="해설" bind:value={explanation} rows={2} />
<TextInput label="출처/메모" bind:value={source_note} />
<label class="flex items-center gap-2 text-xs text-dim cursor-pointer">
<input type="checkbox" bind:checked={is_active} />
<span>출제 활성 (체크 해제 시 복습 후보에서 일시 제외, 데이터는 유지)</span>
</label>
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex gap-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={remove} loading={deleting} variant="ghost" icon={Trash2}>삭제</Button>
</div>
<Button onclick={save} loading={saving} icon={Save}>저장</Button>
</div>
{/if}
</div>
@@ -0,0 +1,199 @@
<script>
/**
* /study/topics/[id]/questions/new — 문제 입력 페이지.
*
* 하루 100문제 입력 시나리오에 맞춰 빠른 반복 입력 UX:
* - "저장 후 계속 입력" → subject/scope/exam_name/exam_round 유지, 본문·보기·정답만 초기화
* - sessionStorage 캐시: 페이지 새로고침해도 분류 필드 유지
* - 입력 검증 실패 시 토스트
*/
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 { ArrowLeft, Save, Repeat } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
// 입력 필드
let q_text = $state('');
let c1 = $state('');
let c2 = $state('');
let c3 = $state('');
let c4 = $state('');
let correct = $state(1);
// persistent (sessionStorage 동기화)
let subject = $state('');
let scope = $state('');
let exam_name = $state('');
let exam_round = $state('');
// 한 번 입력 후 유지 안 함
let explanation = $state('');
let source_note = $state('');
let saving = $state(false);
const STORAGE_KEY = $derived(`study_q_persist_${topicId}`);
function loadPersist() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const p = JSON.parse(raw);
subject = p.subject ?? '';
scope = p.scope ?? '';
exam_name = p.exam_name ?? '';
exam_round = p.exam_round ?? '';
}
} catch {}
}
function savePersist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ subject, scope, exam_name, exam_round }));
} catch {}
}
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
onMount(() => {
loadPersist();
loadTopic();
});
function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
addToast('error', '1~4번 보기를 모두 입력하세요'); return false;
}
if (![1, 2, 3, 4].includes(Number(correct))) {
addToast('error', '정답은 1~4 중 하나'); return false;
}
return true;
}
function clearForCont() {
q_text = ''; c1 = ''; c2 = ''; c3 = ''; c4 = ''; correct = 1;
explanation = ''; source_note = '';
// subject/scope/exam_name/exam_round 는 유지
}
async function save(continueAfter) {
if (!validate()) return;
saving = true;
savePersist();
try {
const body = {
question_text: q_text.trim(),
choice_1: c1.trim(),
choice_2: c2.trim(),
choice_3: c3.trim(),
choice_4: c4.trim(),
correct_choice: Number(correct),
subject: subject || null,
scope: scope || null,
exam_name: exam_name || null,
exam_round: exam_round || null,
explanation: explanation || null,
source_note: source_note || null,
};
await api(`/study-topics/${topicId}/questions`, {
method: 'POST',
body: JSON.stringify(body),
});
addToast('success', '문제 저장됨');
if (continueAfter) {
clearForCont();
// 본문 textarea 로 포커스 이동
setTimeout(() => document.getElementById('q-text')?.focus(), 0);
} else {
goto(`/study/topics/${topicId}`);
}
} catch (err) {
addToast('error', err.detail || '저장 실패');
} finally {
saving = false;
}
}
</script>
<svelte:head><title>새 문제 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">새 문제</span>
</div>
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<Textarea
label="문제 본문"
bind:value={q_text}
rows={3}
placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?"
id="q-text"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} />
<TextInput label="② 2번 보기" bind:value={c2} />
<TextInput label="③ 3번 보기" bind:value={c3} />
<TextInput label="④ 4번 보기" bind:value={c4} />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n}
<button
type="button"
onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n}
>{n}</button>
{/each}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default">
<TextInput label="과목 (유지)" bind:value={subject} placeholder="예: 연소공학" />
<TextInput label="범위 (유지)" bind:value={scope} placeholder="예: 폭발범위" />
<TextInput label="시험명 (유지)" bind:value={exam_name} placeholder="예: 가스기사" />
<TextInput label="회차 (유지)" bind:value={exam_round} placeholder="예: 2024년 1회" />
</div>
<div class="text-[11px] text-dim -mt-2">"유지" 표시 필드는 다음 입력에도 그대로 유지됩니다 (sessionStorage).</div>
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
<TextInput label="출처/메모 (선택)" bind:value={source_note} placeholder="예: 산업안전기사 2023 1회 기출 7번" />
</div>
{/snippet}
</Card>
<div class="flex items-center justify-between gap-2 flex-wrap">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<div class="flex gap-2">
<Button onclick={() => save(false)} loading={saving} variant="ghost" icon={Save}>저장</Button>
<Button onclick={() => save(true)} loading={saving} icon={Repeat}>저장 계속 입력</Button>
</div>
</div>
</div>
@@ -0,0 +1,285 @@
<script>
/**
* /study/topics/[id]/review — 복습모드.
*
* default = 과목별 target_per_subject(=20) 무작위 균등 추출.
* 정답·해설은 답 제출 시점에만 노출 (선노출 금지).
* wrong_only = 가장 최근 attempt 가 오답인 문제만 (latest-wrong, ever-wrong 아님).
* 자동 진행 안 함 — 사용자가 [다음 문제] 클릭으로 넘김.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Play, CheckCircle2, XCircle, RotateCcw } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
// 진입 화면 옵션
let mode = $state('start'); // 'start' | 'playing' | 'done'
let optSubject = $state('');
let optWrongOnly = $state(false);
let optTarget = $state(20);
// 출제 결과
let questions = $state([]);
let total = $state(0);
let distribution = $state({});
// 진행 상태
let cursor = $state(0); // 0-based
let selected = $state(null); // 1~4
let submitted = $state(null); // {is_correct, correct_choice, explanation, stats}
let submitting = $state(false);
// 세션 누적
let answered = $state(0);
let correctCount = $state(0);
let loading = $state(true);
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
topicName = t?.topic?.name ?? '';
} catch {}
}
onMount(async () => {
loading = true;
await loadTopic();
loading = false;
});
async function start() {
loading = true;
try {
const params = new URLSearchParams();
params.set('target_per_subject', String(optTarget));
if (optSubject.trim()) params.set('subject', optSubject.trim());
if (optWrongOnly) params.set('wrong_only', 'true');
const res = await api(`/study-topics/${topicId}/review/questions?${params}`);
questions = res.items;
total = res.total;
distribution = res.subject_distribution || {};
cursor = 0;
answered = 0;
correctCount = 0;
selected = null;
submitted = null;
if (questions.length === 0) {
addToast('info', '출제할 문제가 없습니다.');
} else {
mode = 'playing';
}
} catch (err) {
addToast('error', err.detail || '복습 시작 실패');
} finally {
loading = false;
}
}
async function submit() {
if (selected == null) {
addToast('error', '답을 선택하세요'); return;
}
submitting = true;
try {
const q = questions[cursor];
const res = await api(`/study-questions/${q.id}/attempt`, {
method: 'POST',
body: JSON.stringify({ selected_choice: Number(selected) }),
});
submitted = res;
answered += 1;
if (res.is_correct) correctCount += 1;
} catch (err) {
addToast('error', err.detail || '제출 실패');
} finally {
submitting = false;
}
}
function next() {
if (cursor + 1 >= questions.length) {
mode = 'done';
return;
}
cursor += 1;
selected = null;
submitted = null;
}
function restart() {
mode = 'start';
questions = [];
submitted = null;
selected = null;
cursor = 0;
answered = 0;
correctCount = 0;
}
let currentQ = $derived(questions[cursor] ?? null);
let progress = $derived(questions.length > 0 ? `${cursor + 1} / ${questions.length}` : '0 / 0');
</script>
<svelte:head><title>복습 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">복습</span>
</div>
{#if loading}
<Skeleton h="h-32" rounded="lg" />
{:else if mode === 'start'}
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<h1 class="text-base font-semibold text-text">복습 시작</h1>
<p class="text-xs text-dim">
기본은 과목별 {optTarget}문제씩 무작위 균등 추출. 한 과목이 부족하면 가용한 만큼만 출제됩니다.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<TextInput label="과목 (선택, 비워두면 전체 균등)" bind:value={optSubject} placeholder="예: 연소공학" />
</div>
<div>
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목당 문제 수</span>
<input
type="number"
bind:value={optTarget}
min="1"
max="100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent"
/>
</label>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-text cursor-pointer">
<input type="checkbox" bind:checked={optWrongOnly} />
<span>오답만 (가장 최근 attempt 가 오답인 문제만)</span>
</label>
<div class="flex gap-2 justify-end">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={start} icon={Play}>시작</Button>
</div>
</div>
{/snippet}
</Card>
{:else if mode === 'playing' && currentQ}
<div class="mb-3 flex items-center justify-between text-xs text-dim flex-wrap gap-2">
<span>{progress}</span>
<span>
{#if currentQ.subject}<span>{currentQ.subject}</span>{/if}
{#if currentQ.scope}<span> · {currentQ.scope}</span>{/if}
</span>
<span>정답 {correctCount} / 풀이 {answered}</span>
</div>
<Card>
{#snippet children()}
<div class="p-5 flex flex-col gap-4">
<p class="text-base text-text whitespace-pre-line">{currentQ.question_text}</p>
<div class="flex flex-col gap-2">
{#each currentQ.choices as ch (ch.number)}
{@const isSelected = selected === ch.number}
{@const correctAfter = submitted && submitted.correct_choice === ch.number}
{@const wrongAfter = submitted && submitted.selected_choice === ch.number && !submitted.is_correct}
<button
type="button"
disabled={submitted !== null}
onclick={() => (selected = ch.number)}
class="text-left p-3 rounded-lg border transition-colors flex items-start gap-3
{submitted === null
? (isSelected ? 'border-accent bg-accent/5 text-text' : 'border-default bg-surface text-text hover:border-accent/40')
: (correctAfter ? 'border-success bg-success/10 text-text'
: wrongAfter ? 'border-error bg-error/10 text-text'
: 'border-default bg-surface text-dim')}"
>
<span class="font-semibold w-5 shrink-0">{ch.number}</span>
<span class="flex-1 text-sm">{ch.text}</span>
{#if submitted !== null && correctAfter}
<CheckCircle2 size={16} class="text-success shrink-0" />
{:else if submitted !== null && wrongAfter}
<XCircle size={16} class="text-error shrink-0" />
{/if}
</button>
{/each}
</div>
{#if submitted === null}
<div class="flex justify-end">
<Button onclick={submit} loading={submitting} disabled={selected == null}>제출</Button>
</div>
{:else}
<div class="p-3 rounded border border-default bg-bg/40 text-sm text-text">
<div class="flex items-center gap-2 mb-2">
{#if submitted.is_correct}
<CheckCircle2 size={16} class="text-success" />
<span class="font-semibold text-success">정답</span>
{:else}
<XCircle size={16} class="text-error" />
<span class="font-semibold text-error">오답</span>
<span class="text-dim text-xs">정답: {submitted.correct_choice}</span>
{/if}
</div>
{#if submitted.explanation}
<p class="text-xs text-dim whitespace-pre-line">{submitted.explanation}</p>
{/if}
<div class="text-[11px] text-dim mt-2">
누적 {submitted.stats.attempt_count}회 · 정답 {submitted.stats.correct_count} · 오답 {submitted.stats.wrong_count}
</div>
</div>
<div class="flex justify-end">
<Button onclick={next}>{cursor + 1 >= questions.length ? '결과 보기' : '다음 문제'}</Button>
</div>
{/if}
</div>
{/snippet}
</Card>
{:else if mode === 'done'}
<Card>
{#snippet children()}
<div class="p-6 flex flex-col items-center gap-4 text-center">
<h1 class="text-xl font-semibold text-text">복습 완료</h1>
<div class="text-sm text-dim">
{answered}문제 · <span class="text-success">정답 {correctCount}</span> · <span class="text-error">오답 {answered - correctCount}</span>
{#if answered > 0}
<span> · 정답률 {Math.round((correctCount / answered) * 100)}%</span>
{/if}
</div>
{#if Object.keys(distribution).length > 0}
<div class="text-[11px] text-dim">
분포:
{#each Object.entries(distribution) as [s, n], i}
<span>{i > 0 ? ' · ' : ''}{s || '미분류'} {n}</span>
{/each}
</div>
{/if}
<div class="flex gap-2 mt-2">
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<Button onclick={restart} icon={RotateCcw}>다시 시작</Button>
</div>
</div>
{/snippet}
</Card>
{/if}
</div>