From your notes: the generator now stores a verbatim source quote with every activity (schema + prompt + a server-side clamp so a chatty model cannot drop a card), and the feedback panel shows the sentence that carries it with the quote highlighted green. Decks made before the change, and quotes the model trimmed, fall back to a local IDF-weighted match over the saved notes text (lib/notes-source.ts): nothing is shown unless at least two distinct terms land. The passage hides in exam mode with the other reveals. Read aloud: a Web Speech API speaker control on the text a learner reads (flashcard faces, questions, statements, sentences, instructions) via lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice matched to the deck's language, and no button at all where the browser has no speech synthesis. It used to sit inside the flashcard's flip button (nested buttons, tapping it flipped the card); it now lives next to the hint row. Timed runs: the feed chrome carries a timer chip (off by default) that gives each card 30 seconds; a card that runs out is recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a toast, and the feed moves straight on with a fresh budget. handleAnswered ignores a second result per card, so a timeout plus a manual answer cannot record twice. The generator's type spec now names what each type suits (language decks lean on flashcards/matching/fill blank, ordering needs a real sequence, short answer reasoning), so the deck-aware guidance covers all seven types. New activities-test suite (24 checks: quote path, local path, the green mark, the speech stub, the timed run on Playwright's fake clock), registered in run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs keys for every new string; no new dependencies. Version 0.6.1, SW v83.
157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { useI18n } from "@/lib/i18n";
|
|
import type { Activity } from "@/lib/schemas";
|
|
import { cn, shuffle } from "@/lib/utils";
|
|
import { SpeakButton } from "@/components/SpeakButton";
|
|
|
|
import {
|
|
ActivityHeader,
|
|
FeedbackPanel,
|
|
Hint,
|
|
PromptCard,
|
|
type ActivityComponentProps,
|
|
} from "./shared";
|
|
|
|
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
|
|
|
export function Matching({ activity, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
|
const { t, plural } = useI18n();
|
|
const terms = useMemo(
|
|
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
|
[activity],
|
|
);
|
|
const definitions = useMemo(
|
|
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.definition }))),
|
|
[activity],
|
|
);
|
|
|
|
const [selectedTerm, setSelectedTerm] = useState<number | null>(null);
|
|
const [matched, setMatched] = useState<number[]>([]);
|
|
const [misses, setMisses] = useState(0);
|
|
const [flash, setFlash] = useState<{ term: number; definition: number } | null>(null);
|
|
const [done, setDone] = useState(false);
|
|
|
|
const total = activity.pairs.length;
|
|
const complete = matched.length === total;
|
|
|
|
const onDefinitionTap = (definitionIndex: number) => {
|
|
if (done || selectedTerm === null || matched.includes(definitionIndex)) return;
|
|
if (selectedTerm === definitionIndex) {
|
|
const next = [...matched, definitionIndex];
|
|
setMatched(next);
|
|
setSelectedTerm(null);
|
|
if (next.length === total) {
|
|
const correct = misses === 0;
|
|
setDone(true);
|
|
onAnswered(correct);
|
|
}
|
|
} else {
|
|
setMisses((value) => value + 1);
|
|
setFlash({ term: selectedTerm, definition: definitionIndex });
|
|
window.setTimeout(() => setFlash(null), 500);
|
|
setSelectedTerm(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ActivityHeader
|
|
type="matching"
|
|
topic={activity.topic}
|
|
extra={
|
|
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
|
|
{t("matching.matched", { n: matched.length, total })}
|
|
</span>
|
|
}
|
|
/>
|
|
<PromptCard className="py-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
|
<SpeakButton text={activity.instruction} language={language} />
|
|
</div>
|
|
<Hint>
|
|
{done
|
|
? t("matching.allMatched")
|
|
: selectedTerm === null
|
|
? t("matching.tapTerm")
|
|
: t("matching.tapDef")}
|
|
</Hint>
|
|
</PromptCard>
|
|
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
<div className="flex flex-col gap-2.5">
|
|
{terms.map((term) => {
|
|
const isMatched = matched.includes(term.index);
|
|
const isSelected = selectedTerm === term.index;
|
|
const isFlashing = flash?.term === term.index;
|
|
return (
|
|
<button
|
|
key={`term-${term.index}`}
|
|
type="button"
|
|
disabled={isMatched || done}
|
|
onClick={() => setSelectedTerm(isSelected ? null : term.index)}
|
|
className={cn(
|
|
"min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold backdrop-blur-xl transition-all",
|
|
isMatched
|
|
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
|
: isSelected
|
|
? "border-accent-400 bg-accent-50/80 text-frost-900 dark:bg-accent-500/20 dark:text-white"
|
|
: "border-white/60 bg-white/55 text-frost-950 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50",
|
|
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
|
)}
|
|
>
|
|
<span className="mr-1">{isMatched ? "✓" : "•"}</span>
|
|
{term.text}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
{definitions.map((definition) => {
|
|
const isMatched = matched.includes(definition.index);
|
|
const isFlashing = flash?.definition === definition.index;
|
|
return (
|
|
<button
|
|
key={`def-${definition.index}`}
|
|
type="button"
|
|
disabled={isMatched || done || selectedTerm === null}
|
|
onClick={() => onDefinitionTap(definition.index)}
|
|
className={cn(
|
|
"min-h-16 rounded-2xl border-2 px-3 py-2 text-left text-sm transition-all",
|
|
isMatched
|
|
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
|
|
: "border-white/60 bg-white/55 text-frost-700 hover:bg-white/80 disabled:opacity-60 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-200",
|
|
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
|
|
)}
|
|
>
|
|
{definition.text}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{complete ? (
|
|
<FeedbackPanel
|
|
activity={activity}
|
|
correct={misses === 0}
|
|
explanation={activity.explanation}
|
|
xp={activity.xp}
|
|
isLast={isLast}
|
|
nextLabel={nextLabel}
|
|
onNext={onNext}
|
|
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
|
|
detail={
|
|
<span>
|
|
{misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
|
|
</span>
|
|
}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|