feat(cards): multiple-choice Card-Type mit dynamischen Distractors

- CardTypeSchema: 'multiple-choice' (Felder: front + answer, distractor_pool optional)
- subIndexCount: 'multiple-choice' → 1
- GET /api/v1/decks/:deckId/distractors: N zufällige Feldwerte anderer Karten
  im Deck; field-Allowlist (front/back/answer/question); RANDOM() ORDER; Fallback
  auf distractor_pool wenn Deck < 4 Karten
- fetchDistractors(): Frontend-Client-Funktion
- MultipleChoiceView.svelte: lädt Distractors on mount, shuffelt 4 Optionen,
  zeigt Sofort-Feedback (correct/wrong/neutral), Keyboard 1–4 + Space;
  auto-grade correct→good, wrong→again
- Study-Page: isMultipleChoice + multipleChoiceData derived, Action-Bar
  ausgeblendet, onKey delegiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:28:37 +02:00
parent 0791436107
commit 2b36990e43
6 changed files with 351 additions and 4 deletions

View file

@ -1,10 +1,11 @@
import { and, eq, isNotNull } from 'drizzle-orm';
import { and, eq, isNotNull, ne } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { decks } from '../db/schema/index.ts';
import { cards, decks } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
@ -114,6 +115,51 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
return c.json({ deleted: id });
});
/**
* Liefert N zufällige Feldwerte aus anderen Karten desselben Decks
* als Distractors für multiple-choice-Karten.
* `field` muss in der Allowlist sein (kein freier SQL-Zugriff).
*/
r.get('/:deckId/distractors', async (c) => {
const userId = c.get('userId');
const deckId = c.req.param('deckId');
const cardId = c.req.query('cardId') ?? '';
const countRaw = parseInt(c.req.query('count') ?? '3', 10);
const count = isNaN(countRaw) ? 3 : Math.min(10, Math.max(1, countRaw));
const fieldParam = c.req.query('field') ?? 'back';
const ALLOWED_FIELDS = new Set(['front', 'back', 'answer', 'question']);
if (!ALLOWED_FIELDS.has(fieldParam)) {
return c.json({ error: 'invalid_field' }, 422);
}
const [deck] = await dbOf()
.select({ id: decks.id })
.from(decks)
.where(and(eq(decks.id, deckId), eq(decks.userId, userId)))
.limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
const where = cardId
? and(eq(cards.deckId, deckId), eq(cards.userId, userId), ne(cards.id, cardId))
: and(eq(cards.deckId, deckId), eq(cards.userId, userId));
const rows = await dbOf()
.select({
value: sql<string | null>`jsonb_extract_path_text(${cards.fields}, ${fieldParam})`,
})
.from(cards)
.where(where)
.orderBy(sql`RANDOM()`)
.limit(count);
const distractors = rows
.map((r) => r.value)
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return c.json({ distractors });
});
return r;
}

View file

@ -29,6 +29,18 @@ export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; co
});
}
export function fetchDistractors(
deckId: string,
opts: { cardId?: string; count?: number; field?: string } = {},
) {
const params = new URLSearchParams();
if (opts.cardId) params.set('cardId', opts.cardId);
if (opts.count) params.set('count', String(opts.count));
if (opts.field) params.set('field', opts.field);
const qs = params.size ? `?${params}` : '';
return api<{ distractors: string[] }>(`/api/v1/decks/${deckId}/distractors${qs}`);
}
export function generateDeckFromImage(
files: File | File[],
opts: { language?: 'de' | 'en'; count?: number },

View file

@ -0,0 +1,264 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Rating } from '@cards/domain';
import { fetchDistractors } from '$lib/api/decks.ts';
let {
promptHtml,
answer,
distractorPool,
deckId,
cardId,
ongrade,
}: {
promptHtml: string;
answer: string;
distractorPool?: string;
deckId: string;
cardId: string;
ongrade: (r: Rating) => void;
} = $props();
let options = $state<string[]>([]);
let selected = $state<string | null>(null);
let loading = $state(true);
const correct = $derived(selected === answer);
onMount(async () => {
let distractors: string[] = [];
try {
const data = await fetchDistractors(deckId, {
cardId,
count: 3,
field: 'answer',
});
distractors = data.distractors;
} catch {
// Fallback: try 'back' field (basic/basic-reverse decks)
try {
const data = await fetchDistractors(deckId, { cardId, count: 3, field: 'back' });
distractors = data.distractors;
} catch {}
}
// Fallback auf statischen Pool wenn Deck zu klein
if (distractors.length < 3 && distractorPool) {
const poolItems = distractorPool
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0 && s !== answer);
distractors = [...distractors, ...poolItems].slice(0, 3);
}
options = shuffle([answer, ...distractors.slice(0, 3)]);
loading = false;
});
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pick(option: string) {
if (selected !== null) return;
selected = option;
}
function confirm() {
if (selected === null) return;
ongrade(correct ? 'good' : 'again');
}
function handleKey(e: KeyboardEvent) {
if (selected === null) {
// 14 wählen Option
const idx = ['1', '2', '3', '4'].indexOf(e.key);
if (idx >= 0 && idx < options.length) {
e.stopPropagation();
pick(options[idx]);
}
} else {
// Space/Enter = bestätigen
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
confirm();
}
}
}
</script>
<svelte:window onkeydown={handleKey} />
<div class="mc-view">
<div class="prose">{@html promptHtml}</div>
{#if loading}
<div class="loading" aria-live="polite">Optionen werden geladen …</div>
{:else}
<div class="options" role="group" aria-label="Antwortmöglichkeiten">
{#each options as opt, i (opt)}
{@const isSelected = selected === opt}
{@const isCorrect = opt === answer}
{@const state = selected !== null
? isCorrect
? 'correct'
: isSelected
? 'wrong'
: 'neutral'
: 'idle'}
<button
class="option option-{state}"
onclick={() => pick(opt)}
disabled={selected !== null}
aria-pressed={isSelected}
>
<span class="option-key">{i + 1}</span>
<span class="option-text">{opt}</span>
{#if selected !== null && isCorrect}
<span class="option-icon" aria-hidden="true"></span>
{:else if isSelected && !isCorrect}
<span class="option-icon" aria-hidden="true"></span>
{/if}
</button>
{/each}
</div>
{#if selected !== null}
<button class="confirm-btn" onclick={confirm}>
{correct ? 'Weiter' : 'Verstanden'} <kbd>Space</kbd>
</button>
{/if}
{/if}
</div>
<style>
.mc-view {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.prose {
font-size: 1.125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
}
.prose :global(p) { margin: 0 0 0.875em; }
.prose :global(p:last-child) { margin-bottom: 0; }
.prose :global(h1) {
font-size: 3rem;
font-weight: 700;
text-align: center;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
}
.loading {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 0.5rem 0;
}
.options {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.option {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.9375rem;
text-align: left;
cursor: pointer;
transition: background-color 0.12s ease, border-color 0.12s ease;
width: 100%;
}
.option:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
}
.option:disabled {
cursor: default;
}
.option-correct {
background: hsl(var(--color-success) / 0.12);
border-color: hsl(var(--color-success) / 0.6);
color: hsl(var(--color-foreground));
}
.option-wrong {
background: hsl(var(--color-error) / 0.1);
border-color: hsl(var(--color-error) / 0.5);
color: hsl(var(--color-foreground));
}
.option-neutral {
opacity: 0.5;
}
.option-key {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
background: hsl(var(--color-surface));
}
.option-text {
flex: 1;
}
.option-icon {
flex-shrink: 0;
font-size: 0.875rem;
}
.option-correct .option-icon { color: hsl(var(--color-success)); }
.option-wrong .option-icon { color: hsl(var(--color-error)); }
.confirm-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
font: inherit;
font-size: 0.9375rem;
cursor: pointer;
transition: background-color 0.15s ease;
margin-top: 0.25rem;
}
.confirm-btn:hover {
background: hsl(var(--color-primary) / 0.9);
}
.confirm-btn kbd {
font-size: 0.75rem;
opacity: 0.7;
font-family: inherit;
}
</style>

View file

@ -18,6 +18,7 @@
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
import TypingView from '$lib/components/TypingView.svelte';
import MultipleChoiceView from '$lib/components/MultipleChoiceView.svelte';
import CardSurface from '$lib/components/CardSurface.svelte';
const deckId = $derived(page.params.deckId ?? '');
@ -95,6 +96,17 @@
};
});
const isMultipleChoice = $derived(current?.card?.type === 'multiple-choice');
const multipleChoiceData = $derived.by(() => {
const c = current;
if (!c?.card || c.card.type !== 'multiple-choice') return null;
const fields = c.card.fields as Record<string, string>;
return {
answer: fields.answer ?? '',
distractorPool: fields.distractor_pool || undefined,
};
});
const isTyping = $derived(current?.card?.type === 'typing');
const typingData = $derived.by(() => {
const c = current;
@ -150,6 +162,7 @@
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
if (busy || isDone) return;
if (isTyping) return; // TypingView übernimmt per svelte:window
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
if (!revealed) {
if (e.key === ' ' || e.key === 'Enter') {
@ -241,6 +254,15 @@
activeMaskId={imageOcclusionData.activeMaskId}
{revealed}
/>
{:else if isMultipleChoice && multipleChoiceData}
<MultipleChoiceView
promptHtml={promptHtml}
answer={multipleChoiceData.answer}
distractorPool={multipleChoiceData.distractorPool}
deckId={deckId}
cardId={current?.card_id ?? ''}
ongrade={grade}
/>
{:else if isTyping && typingData}
<TypingView
promptHtml={promptHtml}
@ -267,7 +289,7 @@
</CardSurface>
</div>
<div class="action-bar" class:invisible={isTyping}>
<div class="action-bar" class:invisible={isTyping || isMultipleChoice}>
<!-- grade-row immer im Flow → setzt die Höhe der action-bar -->
<div
class="grade-row"