2026-01-07 18:35:20 +01:00
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
|
|
import { fetchEntries, fetchEntry } from '../api';
|
2026-01-08 01:10:20 +01:00
|
|
|
|
import type { EntryDetail, EntryItems, EntrySummary, QuizQuestionWithEntry } from '../types';
|
2026-01-07 18:35:20 +01:00
|
|
|
|
import VideoPlayer from './VideoPlayer';
|
|
|
|
|
|
|
|
|
|
|
|
type Mode = 'all' | 'selected' | 'single';
|
|
|
|
|
|
|
|
|
|
|
|
const TOTAL_QUESTIONS = 10;
|
|
|
|
|
|
|
|
|
|
|
|
interface QuizRunnerProps {
|
|
|
|
|
|
defaultMode?: Mode;
|
|
|
|
|
|
defaultEntryId?: string;
|
2026-01-07 23:44:45 +01:00
|
|
|
|
autoStart?: boolean;
|
2026-01-07 18:35:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
question: QuizQuestionWithEntry;
|
|
|
|
|
|
response: any;
|
|
|
|
|
|
onChange: (val: any) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const payload = question.payload || {};
|
|
|
|
|
|
const type = question.type || '';
|
|
|
|
|
|
|
|
|
|
|
|
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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{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 });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="radio"
|
|
|
|
|
|
checked={response === idx}
|
|
|
|
|
|
onChange={() => onChange(idx)}
|
|
|
|
|
|
name={`q-${question.id}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>{option}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-01-08 01:10:48 +01:00
|
|
|
|
<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>
|
2026-01-07 18:35:20 +01:00
|
|
|
|
{item.use_note_en && <div className="subline">{item.use_note_en}</div>}
|
2026-01-08 01:10:48 +01:00
|
|
|
|
{item.when_to_use_en && <div className="subline">{item.when_to_use_en}</div>}
|
2026-01-07 18:35:20 +01:00
|
|
|
|
{item.register && <span className="pill pill--ghost">{item.register}</span>}
|
2026-01-08 01:10:48 +01:00
|
|
|
|
{(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>
|
|
|
|
|
|
)}
|
2026-01-07 18:35:20 +01:00
|
|
|
|
<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} />
|
2026-01-08 01:10:48 +01:00
|
|
|
|
{question.ig_meta && (
|
|
|
|
|
|
<div className="ig-block">
|
|
|
|
|
|
{question.ig_meta.profile_pic_url ? (
|
|
|
|
|
|
<a href={question.ig_meta.profile_url || '#'} target="_blank" rel="noreferrer">
|
|
|
|
|
|
<img className="ig-avatar" src={question.ig_meta.profile_pic_url} alt={question.ig_meta.username || 'profile'} />
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="ig-avatar placeholder" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="ig-body">
|
|
|
|
|
|
<div className="ig-row">
|
|
|
|
|
|
{question.ig_meta.username ? (
|
|
|
|
|
|
<a className="ig-name" href={question.ig_meta.profile_url || '#'} target="_blank" rel="noreferrer">
|
|
|
|
|
|
{question.ig_meta.username}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="ig-name">Instagram</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{question.ig_meta.full_name && <span className="muted"> · {question.ig_meta.full_name}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="muted">{question.ig_meta.post_date || ''}</div>
|
|
|
|
|
|
{question.ig_meta.description && <p className="ig-desc">{question.ig_meta.description}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-07 18:35:20 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 23:44:45 +01:00
|
|
|
|
export default function QuizRunner({ defaultMode = 'all', defaultEntryId, autoStart }: QuizRunnerProps) {
|
2026-01-07 18:35:20 +01:00
|
|
|
|
const [entries, setEntries] = useState<EntrySummary[]>([]);
|
|
|
|
|
|
const [loadingEntries, setLoadingEntries] = useState(true);
|
2026-01-08 01:00:51 +01:00
|
|
|
|
const [mode] = useState<Mode>(defaultMode);
|
|
|
|
|
|
const [selectedIds] = useState<string[]>(defaultEntryId ? [defaultEntryId] : []);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
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);
|
2026-01-08 01:10:28 +01:00
|
|
|
|
const [showExplanation, setShowExplanation] = useState(false);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
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(() => {
|
2026-01-08 01:00:51 +01:00
|
|
|
|
if (autoStart && entries.length > 0) {
|
2026-01-07 23:44:45 +01:00
|
|
|
|
startQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [autoStart, defaultEntryId, entries.length]);
|
|
|
|
|
|
|
2026-01-07 18:35:20 +01:00
|
|
|
|
const currentQuestion = useMemo(() => questions[currentIndex], [questions, currentIndex]);
|
|
|
|
|
|
|
|
|
|
|
|
const resetQuestionState = () => {
|
|
|
|
|
|
setResponse(null);
|
|
|
|
|
|
setShowResult(false);
|
|
|
|
|
|
setLastCorrect(false);
|
2026-01-08 01:10:28 +01:00
|
|
|
|
setShowExplanation(false);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const startQuiz = async () => {
|
|
|
|
|
|
const ids: string[] =
|
2026-01-08 01:01:15 +01:00
|
|
|
|
mode === 'all' ? entries.map((e) => e.id) : selectedIds.length ? selectedIds.slice(0, 1) : entries.map((e) => e.id);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
|
|
|
|
|
|
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));
|
2026-01-08 01:10:20 +01:00
|
|
|
|
const details: EntryDetail[] = await Promise.all(uniqueIds.map((id) => fetchEntry(id)));
|
2026-01-07 18:35:20 +01:00
|
|
|
|
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,
|
2026-01-08 01:10:20 +01:00
|
|
|
|
ig_meta: entry.ig_meta,
|
2026-01-07 18:35:20 +01:00
|
|
|
|
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);
|
|
|
|
|
|
setShowResult(true);
|
2026-01-08 01:10:28 +01:00
|
|
|
|
setShowExplanation(!correct);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-01-08 01:01:15 +01:00
|
|
|
|
<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
|
2026-01-07 18:35:20 +01:00
|
|
|
|
</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);
|
2026-01-08 01:10:28 +01:00
|
|
|
|
const shouldShowExplanation = showResult && (!lastCorrect || showExplanation);
|
2026-01-07 18:35:20 +01:00
|
|
|
|
|
|
|
|
|
|
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 && (
|
|
|
|
|
|
<div className={lastCorrect ? 'callout success' : 'callout'}>
|
|
|
|
|
|
{lastCorrect ? 'Correct!' : 'Not quite.'}
|
|
|
|
|
|
{!lastCorrect && correctText && <div className="subline">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)}>
|
|
|
|
|
|
Don’t know
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{showResult && (
|
|
|
|
|
|
<button className="button button--primary" onClick={goNext}>
|
|
|
|
|
|
{currentIndex + 1 === questions.length ? 'Finish' : 'Next'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{showResult && !lastCorrect && <ExplanationPanel question={currentQuestion} targets={targets} />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|