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 { 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue