Files
lang-quiz/client/src/components/QuizRunner.tsx

457 lines
15 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState } from 'react';
import { fetchEntries, fetchEntry } from '../api';
import type { EntryDetail, EntryItems, EntrySummary, QuizQuestionWithEntry } from '../types';
import VideoPlayer from './VideoPlayer';
type Mode = 'all' | 'selected' | 'single';
const TOTAL_QUESTIONS = 10;
interface QuizRunnerProps {
defaultMode?: Mode;
defaultEntryId?: string;
autoStart?: boolean;
}
interface TargetHit {
group: string;
item: Record<string, any>;
}
function shuffle<T>(list: T[]) {
const copy = [...list];
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
const normalize = (val: any) => (val === undefined || val === null ? '' : String(val).trim());
function resolveTargets(question: QuizQuestionWithEntry): TargetHit[] {
const targetIds = new Set((question.targets || []).map((t) => normalize(t)));
const groups: { label: string; items: any[] }[] = [
{ label: 'Grammar', items: question.items?.grammar || [] },
{ label: 'Vocabulary', items: question.items?.vocab || [] },
{ label: 'Key Phrases', items: question.items?.key_phrases || [] },
{ label: 'Conversation', items: question.items?.conversation || [] },
];
const found: TargetHit[] = [];
groups.forEach(({ label, items }) => {
items.forEach((item) => {
if (item?.id && targetIds.has(normalize(item.id))) {
found.push({ group: label, item });
}
});
});
return found;
}
function deriveCorrectText(question: QuizQuestionWithEntry) {
const options: any[] = Array.isArray(question.payload?.options) ? question.payload?.options : [];
if (typeof question.answer?.correct_index === 'number' && options[question.answer.correct_index]) {
return options[question.answer.correct_index];
}
if (question.answer?.correct_text) return question.answer.correct_text;
if (question.payload?.blanked) return question.payload.blanked;
const pairs = Array.isArray(question.payload?.pairs) ? question.payload.pairs : [];
if (pairs.length) {
return pairs.map((p: any) => `${p.left}${p.right}`).join(' | ');
}
return '';
}
function checkClozeAnswer(question: QuizQuestionWithEntry, response: string) {
if (!response) return false;
const expected = [question.answer?.correct_text, question.answer?.correct, question.payload?.blanked].filter(Boolean).map(normalize);
const answer = normalize(response);
return expected.some((val) => val === answer || val.toLowerCase() === answer.toLowerCase());
}
function checkMatchAnswer(question: QuizQuestionWithEntry, response: Record<number, string> | null) {
const pairs: any[] = Array.isArray(question.payload?.pairs) ? question.payload.pairs : [];
if (!pairs.length) return false;
return pairs.every((pair, idx) => {
const expected = normalize(pair.right);
const user = normalize(response?.[idx]);
return expected === user;
});
}
function checkMcAnswer(question: QuizQuestionWithEntry, response: number | null) {
if (typeof response !== 'number') return false;
if (typeof question.answer?.correct_index !== 'number') return false;
return response === question.answer.correct_index;
}
function QuestionRenderer({
question,
response,
onChange,
showResult,
lastCorrect,
}: {
question: QuizQuestionWithEntry;
response: any;
onChange: (val: any) => void;
showResult: boolean;
lastCorrect: boolean;
}) {
const payload = question.payload || {};
const type = question.type || '';
const correctIndex = typeof question.answer?.correct_index === 'number' ? question.answer.correct_index : null;
if (type === 'cloze') {
const sentence = payload.sentence_jp || payload.sentence || '';
return (
<div className="question-block">
{sentence && <div className="muted">{sentence.replace(payload.blanked || '', '____')}</div>}
<input
className="input"
type="text"
placeholder="Type the missing text"
value={response || ''}
onChange={(e) => onChange(e.target.value)}
disabled={showResult}
/>
{Array.isArray(payload.options) && payload.options.length > 0 && (
<div className="option-hints">Hints: {payload.options.join(' • ')}</div>
)}
</div>
);
}
if (type === 'match') {
const pairs: any[] = Array.isArray(payload.pairs) ? payload.pairs : [];
const rightOptions = useMemo(
() => shuffle(pairs.map((p) => p.right).filter(Boolean)),
[question.id, question.entryId]
);
return (
<div className="question-block matches">
{pairs.map((pair, idx) => (
<div key={idx} className="match-row">
<div className="match-left">{pair.left}</div>
<select
className="input"
value={response?.[idx] || ''}
onChange={(e) => {
const current = response && typeof response === 'object' ? response : {};
onChange({ ...current, [idx]: e.target.value });
}}
disabled={showResult}
>
<option value="">Match</option>
{rightOptions.map((opt, optionIdx) => (
<option key={optionIdx} value={opt}>
{opt}
</option>
))}
</select>
</div>
))}
</div>
);
}
const options: any[] = Array.isArray(payload.options) ? payload.options : [];
if (!options.length) {
return <div className="muted">No options provided for this question.</div>;
}
return (
<div className="question-block">
{options.map((option, idx) => (
<label
key={idx}
className={[
'option',
showResult && correctIndex === idx ? 'correct' : '',
showResult && !lastCorrect && response === idx && correctIndex !== idx ? 'incorrect' : '',
].join(' ').trim()}
>
<input
type="radio"
checked={response === idx}
onChange={() => onChange(idx)}
name={`q-${question.id}`}
disabled={showResult}
/>
<span>{option}</span>
</label>
))}
</div>
);
}
import IgMetaBlock from './IgMetaBlock';
function ExplanationPanel({ question, targets }: { question: QuizQuestionWithEntry; targets: TargetHit[] }) {
return (
<div className="explanation">
<h4>Explanation</h4>
{targets.length ? (
<div className="explanation-grid">
{targets.map(({ group, item }) => (
<div key={`${group}-${item.id || item.jp}`} className="panel-card">
<div className="panel-card__title">
{item.jp || item.pattern || item.id}
{item.kana && <div className="subline">{item.kana}</div>}
</div>
<div className="muted">
{item.meaning_en || item.meaning || item.en || item.when_to_use_en || item.note_en}
</div>
{item.use_note_en && <div className="subline">{item.use_note_en}</div>}
{item.when_to_use_en && <div className="subline">{item.when_to_use_en}</div>}
{item.register && <span className="pill pill--ghost">{item.register}</span>}
{(item.example || item.example_en || item.example_jp) && (
<div className="item-row">
<span className="label">Example</span>
<div className="subline">{item.example?.jp || item.example_jp}</div>
<div className="subline muted">{item.example?.kana || item.example_kana}</div>
<div className="subline muted">{item.example?.en || item.example_en}</div>
</div>
)}
<div className="tag">{group}</div>
</div>
))}
</div>
) : (
<p className="muted">No linked study items were found for this question.</p>
)}
<VideoPlayer src={question.video_url} />
{question.ig_meta && <IgMetaBlock ig={question.ig_meta} entryId={question.entryId} />}
</div>
);
}
export default function QuizRunner({ defaultMode = 'all', defaultEntryId, autoStart }: QuizRunnerProps) {
const [entries, setEntries] = useState<EntrySummary[]>([]);
const [loadingEntries, setLoadingEntries] = useState(true);
const [mode] = useState<Mode>(defaultMode);
const [selectedIds] = useState<string[]>(defaultEntryId ? [defaultEntryId] : []);
const [questions, setQuestions] = useState<QuizQuestionWithEntry[]>([]);
const [status, setStatus] = useState<'setup' | 'loading' | 'running' | 'finished'>('setup');
const [currentIndex, setCurrentIndex] = useState(0);
const [score, setScore] = useState(0);
const [response, setResponse] = useState<any>(null);
const [showResult, setShowResult] = useState(false);
const [lastCorrect, setLastCorrect] = useState(false);
const [lastSkipped, setLastSkipped] = useState(false);
const [showExplanation, setShowExplanation] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoadingEntries(true);
fetchEntries()
.then((data) => setEntries(data))
.catch(() => setError('Could not load entries.'))
.finally(() => setLoadingEntries(false));
}, []);
useEffect(() => {
if (autoStart && entries.length > 0) {
startQuiz();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStart, defaultEntryId, entries.length]);
const currentQuestion = useMemo(() => questions[currentIndex], [questions, currentIndex]);
const resetQuestionState = () => {
setResponse(null);
setShowResult(false);
setLastCorrect(false);
setLastSkipped(false);
setShowExplanation(false);
};
const startQuiz = async () => {
const ids: string[] =
mode === 'all' ? entries.map((e) => e.id) : selectedIds.length ? selectedIds.slice(0, 1) : entries.map((e) => e.id);
if (!ids.length) {
setError('Pick at least one entry to quiz on.');
return;
}
setError(null);
setStatus('loading');
try {
const uniqueIds = Array.from(new Set(ids));
const details: EntryDetail[] = await Promise.all(uniqueIds.map((id) => fetchEntry(id)));
const pool: QuizQuestionWithEntry[] = details.flatMap((entry) => {
const safeItems: EntryItems = entry.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] };
return (entry.quiz || []).map((q) => ({
...q,
entryId: entry.id,
entryTitle: entry.title,
items: safeItems,
video_url: entry.video_url,
ig_meta: entry.ig_meta,
targets: q.targets || [],
type: q.type || 'unknown',
payload: q.payload || {},
answer: q.answer || {},
}));
});
if (!pool.length) {
setError('No quiz questions found in the selected entries.');
setStatus('setup');
return;
}
const chosen = shuffle(pool).slice(0, Math.min(TOTAL_QUESTIONS, pool.length));
setQuestions(chosen);
setCurrentIndex(0);
setScore(0);
resetQuestionState();
setStatus('running');
} catch (err: any) {
setError(err?.message || 'Could not start quiz.');
setStatus('setup');
}
};
const handleSubmit = (skip = false) => {
if (!currentQuestion || showResult) return;
let correct = false;
if (!skip) {
if ((currentQuestion.type || '').startsWith('mc') || currentQuestion.type === 'choose_best_reply') {
correct = checkMcAnswer(currentQuestion, response);
} else if (currentQuestion.type === 'cloze') {
correct = checkClozeAnswer(currentQuestion, response);
} else if (currentQuestion.type === 'match') {
correct = checkMatchAnswer(currentQuestion, response);
} else if (typeof currentQuestion.answer?.correct_index === 'number') {
correct = checkMcAnswer(currentQuestion, response);
}
}
if (correct) {
setScore((s) => s + 1);
}
setLastCorrect(correct);
setLastSkipped(skip);
setShowResult(true);
setShowExplanation(!correct);
};
const goNext = () => {
if (currentIndex + 1 >= questions.length) {
setStatus('finished');
} else {
setCurrentIndex((idx) => idx + 1);
resetQuestionState();
}
};
if (loadingEntries) {
return <div className="loading">Loading quiz setup</div>;
}
if (status === 'setup') {
return (
<div className="quiz-setup">
<h2>Building your quiz</h2>
<p className="muted">Preparing 10 questions from {mode === 'single' ? 'this entry' : 'all entries'}.</p>
{error && <div className="error">{error}</div>}
{!autoStart && (
<button className="button button--primary" onClick={startQuiz}>
Start quiz
</button>
)}
</div>
);
}
if (status === 'loading') {
return <div className="loading">Building your quiz</div>;
}
if (status === 'finished') {
return (
<div className="quiz-finished">
<h2>Nice work!</h2>
<p className="muted">You scored {score} out of {questions.length}.</p>
<div className="actions">
<button className="button" onClick={() => setStatus('setup')}>
Play again
</button>
</div>
</div>
);
}
if (!currentQuestion) {
return <div className="error">No questions available.</div>;
}
const targets = resolveTargets(currentQuestion);
const correctText = deriveCorrectText(currentQuestion);
const shouldShowExplanation = showResult && (!lastCorrect || showExplanation);
return (
<div className="quiz-runner">
<div className="quiz-top">
<div>
<p className="eyebrow">{currentQuestion.entryTitle}</p>
<h2>{currentQuestion.prompt_en || 'Answer the prompt'}</h2>
</div>
<div className="score-box">
<div className="muted">{currentIndex + 1} / {questions.length}</div>
<div className="score">Score: {score}</div>
</div>
</div>
<QuestionRenderer
question={currentQuestion}
response={response}
onChange={setResponse}
showResult={showResult}
lastCorrect={lastCorrect}
/>
{showResult && (
<div className={lastSkipped ? 'callout neutral' : lastCorrect ? 'callout success' : 'callout'}>
{lastCorrect ? 'Correct!' : lastSkipped ? 'Answer:' : 'Not quite.'}
{correctText && (
<div className="subline">{lastSkipped ? correctText : `Answer: ${correctText}`}</div>
)}
</div>
)}
<div className="quiz-actions">
{!showResult && (
<>
<button className="button" onClick={() => handleSubmit(false)}>
Submit
</button>
<button className="button button--ghost" onClick={() => handleSubmit(true)}>
Dont know
</button>
</>
)}
{showResult && (
<>
<button className="button button--primary" onClick={goNext}>
{currentIndex + 1 === questions.length ? 'Finish' : 'Next'}
</button>
{lastCorrect && !showExplanation && (
<button className="button button--ghost" onClick={() => setShowExplanation(true)}>
Show explanation
</button>
)}
</>
)}
</div>
{shouldShowExplanation && <ExplanationPanel question={currentQuestion} targets={targets} />}
</div>
);
}