feat(study): 카드 SR 모바일 학습 UI — 복습/그냥공부 2트랙 (B3)
검수 완료 카드를 모바일에서 학습하는 UI. 복습(SR)=앞면 회상→reveal→3단 자기평가(모름/애매/암) / 그냥공부(cram)=덜 본 순 휙휙+봤다(SR 무관). - 새 페이지 /study/cards-study(+page.svelte): landing 트랙선택·진행바·결과(세션 tally)·빈/로딩 상태·cram format 필터·키보드(Space reveal·복습 J/K/L·cram Enter). 아이폰15PM 우선, 세이지 토큰. - '암'(correct) 버튼 stage별 동적 라벨(+3/7/14일·졸업), 모름/애매=내일. correctLabel은 sr_schedule REVIEW_INTERVAL_DAYS 미러(라벨 전용, 산술 정본은 백엔드). - API: /study-cards/due CardItem에 review_stage 추가(복습 큐에서만 채움, 동적 라벨용). _build_card_items(session,cards,stages) 확장, /due는 select(card, progress.review_stage)로 변경. - 진입: 허브 '암기카드 학습' 카드+예정목록 갱신 / 검수 UI 헤더 '학습' 버튼. 검증: py_compile OK · 4차원 적대검토(runes·API계약·SR규칙·UX) 통과(확정 조치 0, 지적 2건 거짓양성). 로컬 vite 빌드 불가(node_modules 부재)→배포가 컴파일 게이트. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-6
@@ -47,6 +47,9 @@ class CardItem(BaseModel):
|
||||
needs_review: bool
|
||||
flagged_by: str | None = None
|
||||
evidence: list[CardEvidence] = []
|
||||
# 복습(SR) 큐에서만 채움 — 정답('암') 시 다음 복습일 미리보기 라벨 계산용
|
||||
# (stage별 동적: +3/7/14일·졸업). deck/검수 응답에선 None.
|
||||
review_stage: int | None = None
|
||||
|
||||
|
||||
class CardQuestionGroup(BaseModel):
|
||||
@@ -86,11 +89,17 @@ _RATE_MAP = {
|
||||
|
||||
|
||||
async def _build_card_items(
|
||||
session: AsyncSession, cards: list[StudyMemoCard]
|
||||
session: AsyncSession,
|
||||
cards: list[StudyMemoCard],
|
||||
stages: dict[int, int | None] | None = None,
|
||||
) -> list[CardItem]:
|
||||
"""카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용."""
|
||||
"""카드 목록 → CardItem(evidence 동반). due/deck 학습 flow 공용.
|
||||
|
||||
stages: card_id → review_stage (복습 큐에서만 전달, 동적 라벨 미리보기용).
|
||||
"""
|
||||
if not cards:
|
||||
return []
|
||||
stages = stages or {}
|
||||
ids = [c.id for c in cards]
|
||||
ev_rows = (
|
||||
await session.execute(
|
||||
@@ -106,7 +115,7 @@ async def _build_card_items(
|
||||
CardItem(
|
||||
id=c.id, source_kind=c.source_kind, format=c.format, cue=c.cue, fact=c.fact,
|
||||
cloze_text=c.cloze_text, needs_review=c.needs_review, flagged_by=c.flagged_by,
|
||||
evidence=ev_by.get(c.id, []),
|
||||
evidence=ev_by.get(c.id, []), review_stage=stages.get(c.id),
|
||||
)
|
||||
for c in cards
|
||||
]
|
||||
@@ -256,7 +265,7 @@ async def due_cards(
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyMemoCard)
|
||||
select(StudyMemoCard, StudyMemoCardProgress.review_stage)
|
||||
.join(StudyMemoCardProgress, StudyMemoCardProgress.card_id == StudyMemoCard.id)
|
||||
.where(
|
||||
StudyMemoCard.user_id == user.id,
|
||||
@@ -272,8 +281,10 @@ async def due_cards(
|
||||
.order_by(StudyMemoCardProgress.due_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
return await _build_card_items(session, list(rows))
|
||||
).all()
|
||||
cards = [r[0] for r in rows]
|
||||
stages = {r[0].id: r[1] for r in rows}
|
||||
return await _build_card_items(session, cards, stages)
|
||||
|
||||
|
||||
@router.post("/{card_id}/rate", response_model=RateResult)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers } from 'lucide-svelte';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
onMount(async () => {
|
||||
@@ -69,13 +69,23 @@
|
||||
</div>
|
||||
<p class="text-xs text-dim">푼 문제에서 AI가 추출한 암기카드(cloze 빈칸 / qa)를 확인하고 승인·수정·폐기. 승인된 카드만 학습에 쓰입니다.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/study/cards-study"
|
||||
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">
|
||||
<Repeat size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">암기카드 학습</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 휙휙 훑어봅니다.</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">예정</div>
|
||||
<ul class="list-disc list-inside space-y-0.5">
|
||||
<li>검수한 암기카드로 복습 (카드 SRS)</li>
|
||||
<li>모바일 암기카드 복습 + 공부 알람</li>
|
||||
<li>애플워치 빠른복습 + 공부 알람(push)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText,
|
||||
ArrowLeft, Check, Pencil, Trash2, X, CheckCheck, FileText, Repeat,
|
||||
} from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
@@ -134,10 +134,11 @@
|
||||
{#if total > 0}
|
||||
<span class="rounded-full bg-accent px-2.5 py-0.5 text-xs font-bold text-white">대기 {total}</span>
|
||||
{/if}
|
||||
<div class="ml-auto flex gap-1.5">
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === '' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('')}>전체</button>
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'cloze' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('cloze')}>cloze</button>
|
||||
<button class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === 'qa' ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}" onclick={() => setFilter('qa')}>qa</button>
|
||||
<Button href="/study/cards-study" variant="secondary" size="sm" icon={Repeat}>학습</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/cards-study — 암기카드 학습 (공부 암기노트 B3, 모바일 우선·아이폰 15 Pro Max 430×932).
|
||||
*
|
||||
* 두 트랙:
|
||||
* 1) 복습(SR) — due 카드를 앞면 회상 → 탭 reveal → 3단 자기평가(모름/애매/암).
|
||||
* backend: GET /study-cards/due · POST /study-cards/{id}/rate (암/애매/모름 → correct/unsure/wrong)
|
||||
* '암'(correct) 버튼은 stage별 다음 복습일을 미리보기(+3/7/14일·졸업), 모름·애매는 내일.
|
||||
* 2) 그냥 공부(cram) — 검수 통과 카드를 덜 본 순서로 휙휙, '봤다'만 기록(SR 무관).
|
||||
* backend: GET /study-cards/deck (format 필터) · POST /study-cards/{id}/view
|
||||
*
|
||||
* 검수(needs_review=false) 카드만 복습 큐 입고(백엔드 양층 게이트). 카드 format = qa/cloze 2종.
|
||||
* 데스크탑 키보드: Space=정답 보기 / 복습 J·K·L=모름·애매·암 / 그냥공부 Enter=봤다.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { ArrowLeft, Repeat, Layers, Eye, BookOpen } 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';
|
||||
|
||||
// sr_schedule.py 단일 source 미러 — '암'(correct) 다음 복습일 라벨 전용(실제 스케줄은 백엔드).
|
||||
const REVIEW_INTERVAL_DAYS = { 1: 3, 2: 7, 3: 14 };
|
||||
function correctLabel(stage) {
|
||||
const ns = (stage ?? 0) + 1;
|
||||
if (ns >= 4) return '졸업';
|
||||
return `+${REVIEW_INTERVAL_DAYS[ns]}일`;
|
||||
}
|
||||
|
||||
let mode = $state('landing'); // 'landing' | 'review' | 'cram'
|
||||
let loading = $state(false);
|
||||
let busy = $state(false); // rate/view 진행 중 더블탭 방지
|
||||
let cards = $state([]);
|
||||
let idx = $state(0);
|
||||
let revealed = $state(false);
|
||||
let done = $state(false);
|
||||
let fmtFilter = $state(''); // '' | 'qa' | 'cloze' (cram)
|
||||
let tally = $state({ correct: 0, unsure: 0, wrong: 0 }); // 복습 결과 집계
|
||||
let seen = $state(0); // 그냥공부 본 카드 수
|
||||
let dueCount = $state(null); // landing 배지
|
||||
|
||||
let current = $derived(cards[idx] ?? null);
|
||||
let total = $derived(cards.length);
|
||||
let pct = $derived(total ? Math.round((idx / total) * 100) : 0);
|
||||
|
||||
let _dueCache = null; // landing prefetch 한 due 카드 재사용
|
||||
|
||||
async function fetchDue() {
|
||||
const d = (await api('/study-cards/due?limit=50')) ?? [];
|
||||
_dueCache = d;
|
||||
dueCount = d.length;
|
||||
return d;
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
mode = 'review';
|
||||
loading = true;
|
||||
done = false;
|
||||
idx = 0;
|
||||
revealed = false;
|
||||
tally = { correct: 0, unsure: 0, wrong: 0 };
|
||||
try {
|
||||
cards = _dueCache ?? (await fetchDue());
|
||||
_dueCache = null; // 소비
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '복습 카드 조회 실패');
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCram() {
|
||||
mode = 'cram';
|
||||
loading = true;
|
||||
done = false;
|
||||
idx = 0;
|
||||
revealed = false;
|
||||
seen = 0;
|
||||
try {
|
||||
const q = fmtFilter ? `?format=${fmtFilter}&limit=40` : '?limit=40';
|
||||
cards = (await api(`/study-cards/deck${q}`)) ?? [];
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '학습 덱 조회 실패');
|
||||
cards = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToLanding() {
|
||||
mode = 'landing';
|
||||
cards = [];
|
||||
idx = 0;
|
||||
revealed = false;
|
||||
done = false;
|
||||
fetchDue().catch(() => { dueCount = null; });
|
||||
}
|
||||
|
||||
function advance() {
|
||||
revealed = false;
|
||||
if (idx + 1 >= cards.length) done = true;
|
||||
else idx += 1;
|
||||
}
|
||||
|
||||
async function rate(label) {
|
||||
if (!current || busy) return;
|
||||
busy = true;
|
||||
const c = current;
|
||||
try {
|
||||
await api(`/study-cards/${c.id}/rate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ outcome: label }),
|
||||
});
|
||||
const key = label === '암' ? 'correct' : label === '애매' ? 'unsure' : 'wrong';
|
||||
tally = { ...tally, [key]: tally[key] + 1 };
|
||||
advance();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '평가 저장 실패');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markSeen() {
|
||||
if (!current || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api(`/study-cards/${current.id}/view`, { method: 'POST' });
|
||||
seen += 1;
|
||||
advance();
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '기록 실패');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setCramFilter(f) {
|
||||
if (fmtFilter === f) return;
|
||||
fmtFilter = f;
|
||||
startCram();
|
||||
}
|
||||
|
||||
// 카드 앞면 텍스트 (cloze=빈칸 문장 / qa=질문)
|
||||
function frontText(c) {
|
||||
if (c.format === 'cloze' && c.cloze_text) return c.cloze_text;
|
||||
return c.cue;
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (mode === 'landing' || loading || done || !current) return;
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!revealed) revealed = true;
|
||||
return;
|
||||
}
|
||||
if (!revealed) return;
|
||||
if (mode === 'review') {
|
||||
if (e.key === 'j' || e.key === 'J') rate('모름');
|
||||
else if (e.key === 'k' || e.key === 'K') rate('애매');
|
||||
else if (e.key === 'l' || e.key === 'L') rate('암');
|
||||
} else if (mode === 'cram' && e.key === 'Enter') {
|
||||
markSeen();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', onKey);
|
||||
const m = new URLSearchParams(window.location.search).get('mode');
|
||||
if (m === 'review') startReview();
|
||||
else if (m === 'cram') startCram();
|
||||
else fetchDue().catch(() => { dueCount = null; });
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<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="mb-4 flex items-center gap-2">
|
||||
{#if mode === 'landing'}
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
|
||||
<h1 class="text-lg font-bold text-text">암기카드 학습</h1>
|
||||
{:else}
|
||||
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={backToLanding}>나가기</Button>
|
||||
{#if !done && total > 0}
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-default">
|
||||
<div class="h-full rounded-full bg-accent transition-all" style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs tabular-nums text-dim">{Math.min(idx + 1, total)} / {total}</span>
|
||||
{:else}
|
||||
<h1 class="text-lg font-bold text-text">{mode === 'review' ? '복습' : '그냥 공부'}</h1>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mode === 'cram' && !loading && !done}
|
||||
<!-- 그냥 공부 format 필터 (전환 시 덱 재시작) -->
|
||||
<div class="mb-3 flex gap-1.5">
|
||||
{#each [['', '전체'], ['cloze', 'cloze'], ['qa', 'qa']] as [val, label] (val)}
|
||||
<button
|
||||
class="rounded-full border px-2.5 py-1 text-xs font-semibold {fmtFilter === val ? 'border-accent bg-accent text-white' : 'border-default bg-surface text-dim'}"
|
||||
onclick={() => setCramFilter(val)}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'landing'}
|
||||
<!-- 트랙 선택 -->
|
||||
<p class="mb-4 text-sm text-dim">검수 완료한 암기카드를 학습합니다. 두 가지 방법 중 선택하세요.</p>
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
>
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<Repeat size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">복습 (간격반복)</h2>
|
||||
{#if dueCount !== null && dueCount > 0}
|
||||
<span class="ml-auto rounded-full bg-accent px-2 py-0.5 text-xs font-bold text-white">오늘 {dueCount}장</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-dim">
|
||||
오늘 복습할 카드를 앞면만 보고 떠올린 뒤 <b class="text-text">모름·애매·암</b>으로 자기평가합니다.
|
||||
암이면 복습 간격이 늘고(1·3·7·14일), 애매·모름은 내일 다시 나옵니다.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={startCram}
|
||||
class="block w-full rounded-card border border-default bg-surface p-5 text-left transition-colors hover:border-accent hover:bg-accent/5"
|
||||
>
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<Layers size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">그냥 공부 (휙휙)</h2>
|
||||
</div>
|
||||
<p class="text-xs text-dim">
|
||||
덜 본 카드부터 빠르게 넘겨보며 <b class="text-text">봤다</b>만 기록합니다. 간격반복(SR)과 무관 — 가볍게 훑을 때.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if loading}
|
||||
<div class="flex-1 space-y-3"><Skeleton class="h-64 w-full" /><Skeleton class="h-12 w-full" /></div>
|
||||
|
||||
{:else if done}
|
||||
<!-- 결과 화면 -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center text-center">
|
||||
{#if mode === 'review'}
|
||||
<div class="text-lg font-bold text-text">오늘 카드 복습 완료</div>
|
||||
<div class="my-6 flex gap-9">
|
||||
<div><div class="text-3xl font-extrabold text-success">{tally.correct}</div><div class="text-xs text-dim">암</div></div>
|
||||
<div><div class="text-3xl font-extrabold text-warning">{tally.unsure}</div><div class="text-xs text-dim">애매</div></div>
|
||||
<div><div class="text-3xl font-extrabold text-error">{tally.wrong}</div><div class="text-xs text-dim">모름</div></div>
|
||||
</div>
|
||||
<p class="text-xs text-dim">애매·모름 카드는 내일 복습 큐에 다시 올라옵니다. 암 카드는 간격만큼 쉬어요.</p>
|
||||
{:else}
|
||||
<div class="text-lg font-bold text-text">훑어보기 완료</div>
|
||||
<div class="my-6 text-3xl font-extrabold text-accent">{seen}<span class="ml-1 text-sm font-medium text-dim">장</span></div>
|
||||
<p class="text-xs text-dim">'봤다'로 기록한 카드는 다음에 덜 본 순서에서 뒤로 갑니다.</p>
|
||||
{/if}
|
||||
<div class="mt-7 flex gap-2">
|
||||
<Button variant="secondary" onclick={backToLanding}>다시 고르기</Button>
|
||||
<Button variant="primary" onclick={() => goto('/study')}>공부 허브로</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if total === 0}
|
||||
<!-- 빈 큐 -->
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
{#if mode === 'review'}
|
||||
<EmptyState
|
||||
title="오늘 복습할 카드가 없습니다"
|
||||
description="자기평가에서 애매·모름으로 표시한 카드가 복습일이 되면 여기에 나타납니다. '그냥 공부'로 가볍게 훑어볼 수 있어요."
|
||||
icon={Repeat}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title="학습할 카드가 없습니다"
|
||||
description="문제를 풀면 AI가 암기카드를 추출하고, 검수를 마친 카드가 여기에 쌓입니다."
|
||||
icon={BookOpen}
|
||||
/>
|
||||
{/if}
|
||||
</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">
|
||||
<span
|
||||
class="self-start 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>
|
||||
|
||||
<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}
|
||||
</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">키보드: 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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user