From 2b36990e4315ac20cce5c777b8fdff11acefd9d9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 10 May 2026 15:28:37 +0200 Subject: [PATCH] feat(cards): multiple-choice Card-Type mit dynamischen Distractors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/routes/decks.ts | 50 +++- apps/web/src/lib/api/decks.ts | 12 + .../lib/components/MultipleChoiceView.svelte | 264 ++++++++++++++++++ .../src/routes/study/[deckId]/+page.svelte | 24 +- packages/cards-domain/src/fsrs.ts | 2 + packages/cards-domain/src/schemas/card.ts | 3 +- 6 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/lib/components/MultipleChoiceView.svelte diff --git a/apps/api/src/routes/decks.ts b/apps/api/src/routes/decks.ts index 0593130..ca9a120 100644 --- a/apps/api/src/routes/decks.ts +++ b/apps/api/src/routes/decks.ts @@ -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`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; } diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index b852c5d..45cbeaa 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -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 }, diff --git a/apps/web/src/lib/components/MultipleChoiceView.svelte b/apps/web/src/lib/components/MultipleChoiceView.svelte new file mode 100644 index 0000000..03a0233 --- /dev/null +++ b/apps/web/src/lib/components/MultipleChoiceView.svelte @@ -0,0 +1,264 @@ + + + + +
+
{@html promptHtml}
+ + {#if loading} +
Optionen werden geladen …
+ {:else} +
+ {#each options as opt, i (opt)} + {@const isSelected = selected === opt} + {@const isCorrect = opt === answer} + {@const state = selected !== null + ? isCorrect + ? 'correct' + : isSelected + ? 'wrong' + : 'neutral' + : 'idle'} + + {/each} +
+ + {#if selected !== null} + + {/if} + {/if} +
+ + diff --git a/apps/web/src/routes/study/[deckId]/+page.svelte b/apps/web/src/routes/study/[deckId]/+page.svelte index 233ba17..6a3fe11 100644 --- a/apps/web/src/routes/study/[deckId]/+page.svelte +++ b/apps/web/src/routes/study/[deckId]/+page.svelte @@ -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; + 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} + {:else if isTyping && typingData} -
+
; @@ -50,7 +51,7 @@ export function validateFieldsForType( 'image-occlusion': ['image_ref', 'mask_regions'], 'audio-front': ['audio_ref', 'back'], typing: ['front', 'answer'], - 'multiple-choice': ['question', 'options', 'correct_index'], + 'multiple-choice': ['front', 'answer'], }; const need = required[type] ?? []; const missing = need.filter((k) => !(k in fields));