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:
parent
0791436107
commit
2b36990e43
6 changed files with 351 additions and 4 deletions
|
|
@ -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 { Hono } from 'hono';
|
||||||
|
|
||||||
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
|
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
|
||||||
|
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
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 { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||||
import { ulid } from '../lib/ulid.ts';
|
import { ulid } from '../lib/ulid.ts';
|
||||||
|
|
||||||
|
|
@ -114,6 +115,51 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
return c.json({ deleted: id });
|
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;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export function generateDeckFromImage(
|
||||||
files: File | File[],
|
files: File | File[],
|
||||||
opts: { language?: 'de' | 'en'; count?: number },
|
opts: { language?: 'de' | 'en'; count?: number },
|
||||||
|
|
|
||||||
264
apps/web/src/lib/components/MultipleChoiceView.svelte
Normal file
264
apps/web/src/lib/components/MultipleChoiceView.svelte
Normal 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) {
|
||||||
|
// 1–4 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>
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
||||||
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
|
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
|
||||||
import TypingView from '$lib/components/TypingView.svelte';
|
import TypingView from '$lib/components/TypingView.svelte';
|
||||||
|
import MultipleChoiceView from '$lib/components/MultipleChoiceView.svelte';
|
||||||
import CardSurface from '$lib/components/CardSurface.svelte';
|
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||||
|
|
||||||
const deckId = $derived(page.params.deckId ?? '');
|
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 isTyping = $derived(current?.card?.type === 'typing');
|
||||||
const typingData = $derived.by(() => {
|
const typingData = $derived.by(() => {
|
||||||
const c = current;
|
const c = current;
|
||||||
|
|
@ -150,6 +162,7 @@
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||||
if (busy || isDone) return;
|
if (busy || isDone) return;
|
||||||
if (isTyping) return; // TypingView übernimmt per svelte:window
|
if (isTyping) return; // TypingView übernimmt per svelte:window
|
||||||
|
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
|
||||||
|
|
||||||
if (!revealed) {
|
if (!revealed) {
|
||||||
if (e.key === ' ' || e.key === 'Enter') {
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
|
@ -241,6 +254,15 @@
|
||||||
activeMaskId={imageOcclusionData.activeMaskId}
|
activeMaskId={imageOcclusionData.activeMaskId}
|
||||||
{revealed}
|
{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}
|
{:else if isTyping && typingData}
|
||||||
<TypingView
|
<TypingView
|
||||||
promptHtml={promptHtml}
|
promptHtml={promptHtml}
|
||||||
|
|
@ -267,7 +289,7 @@
|
||||||
</CardSurface>
|
</CardSurface>
|
||||||
</div>
|
</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 -->
|
<!-- grade-row immer im Flow → setzt die Höhe der action-bar -->
|
||||||
<div
|
<div
|
||||||
class="grade-row"
|
class="grade-row"
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ export function subIndexCount(type: string): number {
|
||||||
return 1;
|
return 1;
|
||||||
case 'multiple-choice':
|
case 'multiple-choice':
|
||||||
return 1;
|
return 1;
|
||||||
|
case 'multiple-choice':
|
||||||
|
return 1;
|
||||||
case 'cloze':
|
case 'cloze':
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'subIndexCount("cloze") not supported — use subIndexCountForCloze(text) from @cards/domain'
|
'subIndexCount("cloze") not supported — use subIndexCountForCloze(text) from @cards/domain'
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const CardTypeSchema = z.enum([
|
||||||
'image-occlusion',
|
'image-occlusion',
|
||||||
'audio-front',
|
'audio-front',
|
||||||
'typing',
|
'typing',
|
||||||
|
'multiple-choice',
|
||||||
]);
|
]);
|
||||||
export type CardType = z.infer<typeof CardTypeSchema>;
|
export type CardType = z.infer<typeof CardTypeSchema>;
|
||||||
|
|
||||||
|
|
@ -50,7 +51,7 @@ export function validateFieldsForType(
|
||||||
'image-occlusion': ['image_ref', 'mask_regions'],
|
'image-occlusion': ['image_ref', 'mask_regions'],
|
||||||
'audio-front': ['audio_ref', 'back'],
|
'audio-front': ['audio_ref', 'back'],
|
||||||
typing: ['front', 'answer'],
|
typing: ['front', 'answer'],
|
||||||
'multiple-choice': ['question', 'options', 'correct_index'],
|
'multiple-choice': ['front', 'answer'],
|
||||||
};
|
};
|
||||||
const need = required[type] ?? [];
|
const need = required[type] ?? [];
|
||||||
const missing = need.filter((k) => !(k in fields));
|
const missing = need.filter((k) => !(k in fields));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue