feat(study): 암기카드 학습 데스크탑 Focus Stage — 반응형(좌 진행트랙·중앙 무대카드·우 근거)

데스크탑서 좁은 카드 하나만 휑하던 문제 해결. 모바일 단일 카드는 그대로, md+ 에서 3밴드 그리드.
- 좌: 진행 n/total + 카드별 결과 점(marks: correct/unsure/wrong/seen/flagged) + 집계
- 중앙: 무대 카드(max-w-600·확대 타이포·shadow), 평가 버튼
- 우: reveal 시 근거 fade-in(자리 예약=레이아웃 점프 0), 미reveal 시 빈 칸
시안 A(Focus Stage) 채택. 컨테이너 md:max-w-5xl, 랜딩 md:max-w-xl 제약.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 15:07:03 +09:00
parent 4e9548a8c0
commit 57ad812c6f
@@ -42,6 +42,23 @@
let tally = $state({ correct: 0, unsure: 0, wrong: 0 }); // 복습 결과 집계
let seen = $state(0); // 그냥공부 본 카드 수
let dueCount = $state(null); // landing 배지
let marks = $state([]); // 카드별 결과 (데스크탑 좌측 진행트랙 점): 'correct'|'unsure'|'wrong'|'seen'|'flagged'
function setMark(kind) {
const m = [...marks];
m[idx] = kind;
marks = m;
}
// 데스크탑 진행트랙 점 클래스 (크기+색). 현재 카드는 accent 세로막대, 지난 카드는 결과색.
function dotClass(i) {
if (i === idx && !done) return 'h-4 w-1.5 bg-accent';
const k = marks[i];
if (k === 'correct') return 'h-1.5 w-1.5 bg-success';
if (k === 'unsure') return 'h-1.5 w-1.5 bg-warning';
if (k === 'wrong') return 'h-1.5 w-1.5 bg-error';
if (k === 'seen' || k === 'flagged') return 'h-1.5 w-1.5 bg-faint';
return 'h-1.5 w-1.5 bg-default';
}
let current = $derived(cards[idx] ?? null);
let total = $derived(cards.length);
@@ -63,6 +80,7 @@
idx = 0;
revealed = false;
tally = { correct: 0, unsure: 0, wrong: 0 };
marks = [];
try {
cards = _dueCache ?? (await fetchDue());
_dueCache = null; // 소비
@@ -81,6 +99,7 @@
idx = 0;
revealed = false;
seen = 0;
marks = [];
try {
const q = fmtFilter ? `?format=${fmtFilter}&limit=40` : '?limit=40';
cards = (await api(`/study-cards/deck${q}`)) ?? [];
@@ -118,6 +137,7 @@
});
const key = label === '암' ? 'correct' : label === '애매' ? 'unsure' : 'wrong';
tally = { ...tally, [key]: tally[key] + 1 };
setMark(key);
advance();
} catch (err) {
addToast('error', err?.detail || '평가 저장 실패');
@@ -132,6 +152,7 @@
try {
await api(`/study-cards/${current.id}/view`, { method: 'POST' });
seen += 1;
setMark('seen');
advance();
} catch (err) {
addToast('error', err?.detail || '기록 실패');
@@ -149,6 +170,7 @@
try {
await api(`/study-cards/${c.id}`, { method: 'PATCH', body: JSON.stringify({ needs_review: true }) });
addToast('success', '검수함으로 보냈어요 — 이 카드는 학습에서 빠집니다');
setMark('flagged');
advance();
} catch (err) {
addToast('error', err?.detail || '신고 처리 실패');
@@ -198,7 +220,7 @@
<svelte:head><title>암기카드 학습</title></svelte:head>
<div class="mx-auto flex min-h-[100dvh] max-w-md flex-col px-4 py-4 sm:py-6">
<div class="mx-auto flex min-h-[100dvh] max-w-md flex-col px-4 py-4 sm:py-6 md:max-w-5xl md:px-6">
<!-- 헤더 -->
<div class="mb-4 flex items-center gap-2">
{#if mode === 'landing'}
@@ -231,8 +253,8 @@
{#if mode === 'landing'}
<!-- 트랙 선택 -->
<p class="mb-4 text-sm text-dim">검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.</p>
<div class="space-y-3">
<p class="mb-4 text-sm text-dim md:mx-auto md:max-w-xl">검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.</p>
<div class="space-y-3 md:mx-auto md:max-w-xl">
<button
onclick={startReview}
class="block w-full rounded-card border border-default bg-surface p-5 text-left transition-colors hover:border-accent hover:bg-accent/5"
@@ -308,78 +330,108 @@
</div>
{:else if current}
<!-- 카드 -->
<div class="flex flex-1 flex-col">
<div class="flex flex-1 flex-col rounded-card border border-default bg-surface p-5">
<div class="flex items-center justify-between gap-2">
<span
class="rounded-full px-2.5 py-0.5 text-[10px] font-bold text-white {current.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}"
>{current.format}</span>
<button
type="button"
onclick={flagCard}
disabled={flagBusy || busy}
class="flex items-center gap-1 text-[11px] text-faint transition-colors hover:text-warning disabled:opacity-50"
title="카드 내용이 이상하면 검수함으로 보냅니다"
>
<Flag size={12} /> 이 카드 이상해요
</button>
<!-- 카드 — 모바일: 단일 카드 / 데스크탑(md+): Focus Stage(좌 진행트랙·중앙 무대카드·우 근거) -->
<div class="flex flex-1 flex-col md:grid md:grid-cols-[170px_minmax(0,1fr)_260px] md:items-center md:gap-6">
<!-- 좌측 진행 트랙 (데스크탑 전용) -->
<div class="hidden md:flex md:flex-col md:items-center md:justify-center md:self-stretch md:py-4">
<div class="text-xs font-bold tabular-nums text-dim">{Math.min(idx + 1, total)} / {total}</div>
<div class="my-4 flex max-h-[280px] flex-col items-center gap-1 overflow-y-auto">
{#each cards as _, i (i)}
<span class="shrink-0 rounded-full transition-all {dotClass(i)}"></span>
{/each}
</div>
<div class="mt-3 text-[10px] font-bold uppercase tracking-wide text-faint">
앞 — {current.format === 'qa' ? '질문' : '회상'}
</div>
<div class="mt-1 text-lg font-semibold leading-relaxed text-text">{frontText(current)}</div>
{#if revealed}
<div class="mt-4 rounded-lg border border-accent-ring bg-bg px-4 py-3">
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">정답</div>
<div class="mt-0.5 text-xl font-bold text-accent">{current.fact}</div>
{#if current.evidence?.length && current.evidence[0].snippet}
<div class="mt-2 text-[11px] leading-relaxed text-dim">근거: {current.evidence[0].snippet}</div>
{/if}
</div>
{/if}
{#if !revealed}
<button
onclick={() => (revealed = true)}
class="mt-auto flex items-center justify-center gap-2 rounded-md border border-dashed border-accent-ring bg-surface-hover py-3 text-sm font-medium text-accent transition-colors hover:bg-accent/5"
>
<Eye size={16} /> 탭하여 정답 보기 <span class="text-faint">(Space)</span>
</button>
{#if mode === 'review'}
<div class="text-center text-[11px] leading-relaxed text-dim"><b class="text-success">{tally.correct}</b> · 애매 <b class="text-warning">{tally.unsure}</b> · 모름 <b class="text-error">{tally.wrong}</b></div>
{:else}
<div class="text-center text-[11px] text-dim">본 카드 <b class="text-accent">{seen}</b></div>
{/if}
</div>
<!-- 평가/액션 (reveal 후) -->
{#if revealed}
{#if mode === 'review'}
<div class="mt-3 grid grid-cols-3 gap-2">
<!-- 중앙 무대 카드 + 평가 -->
<div class="flex flex-1 flex-col md:block md:w-full md:max-w-[600px] md:justify-self-center">
<div class="flex flex-1 flex-col rounded-card border border-default bg-surface p-5 md:flex-none md:min-h-[360px] md:p-8 md:shadow-md">
<div class="flex items-center justify-between gap-2">
<span
class="rounded-full px-2.5 py-0.5 text-[10px] font-bold text-white {current.format === 'cloze' ? 'bg-accent' : 'bg-domain-engineering'}"
>{current.format}</span>
<button
onclick={() => rate('모름')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-error py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>모름<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('애매')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-warning py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>애매<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('암')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-success py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
><span class="mt-0.5 text-[10px] font-medium opacity-85">{correctLabel(current.review_stage)}</span></button>
type="button"
onclick={flagCard}
disabled={flagBusy || busy}
class="flex items-center gap-1 text-[11px] text-faint transition-colors hover:text-warning disabled:opacity-50"
title="카드 내용이 이상하면 검수함으로 보냅니다"
>
<Flag size={12} /> 이 카드 이상해요
</button>
</div>
<p class="mt-2 hidden text-center text-[11px] text-faint sm:block">키보드: J 모름 · K 애매 · L 암</p>
{:else}
<button
onclick={markSeen}
disabled={busy}
class="mt-3 w-full rounded-lg bg-accent py-3.5 text-sm font-bold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>봤다 — 다음 <span class="text-xs font-medium opacity-85">(Enter)</span></button>
<div class="mt-3 text-[10px] font-bold uppercase tracking-wide text-faint">
앞 — {current.format === 'qa' ? '질문' : '회상'}
</div>
<div class="mt-1 text-lg font-semibold leading-relaxed text-text md:mt-2 md:text-2xl">{frontText(current)}</div>
{#if revealed}
<div class="mt-4 rounded-lg border border-accent-ring bg-bg px-4 py-3 md:mt-6">
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">정답</div>
<div class="mt-0.5 text-xl font-bold text-accent md:text-3xl">{current.fact}</div>
{#if current.evidence?.length && current.evidence[0].snippet}
<div class="mt-2 text-[11px] leading-relaxed text-dim md:hidden">근거: {current.evidence[0].snippet}</div>
{/if}
</div>
{/if}
{#if !revealed}
<button
onclick={() => (revealed = true)}
class="mt-auto flex items-center justify-center gap-2 rounded-md border border-dashed border-accent-ring bg-surface-hover py-3 text-sm font-medium text-accent transition-colors hover:bg-accent/5"
>
<Eye size={16} /> 탭하여 정답 보기 <span class="text-faint">(Space)</span>
</button>
{/if}
</div>
<!-- 평가/액션 (reveal 후) -->
{#if revealed}
{#if mode === 'review'}
<div class="mt-3 grid grid-cols-3 gap-2">
<button
onclick={() => rate('모름')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-error py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>모름<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('애매')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-warning py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>애매<span class="mt-0.5 text-[10px] font-medium opacity-85">내일</span></button>
<button
onclick={() => rate('암')}
disabled={busy}
class="flex flex-col items-center rounded-lg bg-success py-3.5 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
><span class="mt-0.5 text-[10px] font-medium opacity-85">{correctLabel(current.review_stage)}</span></button>
</div>
<p class="mt-2 hidden text-center text-[11px] text-faint sm:block">키보드: Space 정답 · J 모름 · K 애매 · L 암</p>
{:else}
<button
onclick={markSeen}
disabled={busy}
class="mt-3 w-full rounded-lg bg-accent py-3.5 text-sm font-bold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>봤다 — 다음 <span class="text-xs font-medium opacity-85">(Enter)</span></button>
{/if}
{/if}
{/if}
</div>
<!-- 우측 근거 (데스크탑 전용; reveal 시 채움, 자리 예약으로 레이아웃 점프 0) -->
<div class="hidden md:flex md:flex-col md:self-stretch md:justify-center">
{#if revealed && current.evidence?.length && current.evidence[0].snippet}
<div class="rounded-lg border border-default bg-surface p-4">
<div class="text-[10px] font-bold uppercase tracking-wide text-faint">근거</div>
<div class="mt-1.5 text-xs leading-relaxed text-dim">{current.evidence[0].snippet}</div>
</div>
{:else if revealed}
<div class="rounded-lg border border-dashed border-default p-4 text-[11px] leading-relaxed text-faint">확정 풀이에서 추출한 카드 — 별도 인용 없음</div>
{/if}
</div>
</div>
{/if}
</div>