Public Access
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
"use no memo";
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useTranslation } from 'react-i18next'
|
||
import type { Exam, ExamState, Question } from "../types";
|
||
import { gradeQuestion } from "../utils/grading";
|
||
import { formatTime } from "../utils/formatting";
|
||
import * as aiApi from "../api/aiApi";
|
||
import Tex from "./Tex";
|
||
import QuestionMedia from "./QuestionMedia";
|
||
import QuestionRenderer from "./questions/QuestionRenderer";
|
||
|
||
interface Props {
|
||
exam: Exam;
|
||
state: ExamState;
|
||
setState: React.Dispatch<React.SetStateAction<ExamState>>;
|
||
onSubmit: () => void;
|
||
onExit: () => void;
|
||
onNavigate?: (i: number) => void;
|
||
n?: string;
|
||
}
|
||
|
||
export default function ExamRunner({
|
||
exam,
|
||
state,
|
||
setState,
|
||
onSubmit,
|
||
onExit,
|
||
onNavigate,
|
||
n,
|
||
}: Readonly<Props>) {
|
||
const { t, i18n } = useTranslation()
|
||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||
const { answers, flags, startedAt, feedbackMode } = state;
|
||
const currentIdx = urlIdx;
|
||
const q = exam.questions[currentIdx];
|
||
const total = exam.questions.length;
|
||
const notes = state.notes ?? "";
|
||
|
||
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
|
||
useEffect(() => {
|
||
setRevealed(!!state.revealed?.[q.id]);
|
||
}, [currentIdx, q.id, state.revealed]);
|
||
|
||
const [hintCache, setHintCache] = useState<Record<string, string>>({});
|
||
const [hintLoading, setHintLoading] = useState(false);
|
||
const [hintError, setHintError] = useState<string | null>(null);
|
||
const currentHint = hintCache[q.id];
|
||
|
||
useEffect(() => {
|
||
setHintError(null);
|
||
}, [q.id]);
|
||
|
||
const requestHint = async () => {
|
||
if (currentHint || hintLoading) return;
|
||
setHintLoading(true);
|
||
setHintError(null);
|
||
try {
|
||
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
|
||
delete safeQ.answer;
|
||
delete safeQ.acceptedForms;
|
||
const res = await aiApi.getHint({
|
||
question: safeQ,
|
||
language: i18n.language || 'en',
|
||
});
|
||
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
|
||
} catch (e) {
|
||
setHintError((e as Error).message);
|
||
} finally {
|
||
setHintLoading(false);
|
||
}
|
||
};
|
||
|
||
const setAnswer = (val: unknown) =>
|
||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
||
|
||
const goTo = useCallback(
|
||
(i: number) => {
|
||
const next = Math.max(0, Math.min(total - 1, i));
|
||
setState((s) => ({ ...s, currentIdx: next }));
|
||
onNavigate?.(next);
|
||
},
|
||
[total, onNavigate],
|
||
);
|
||
|
||
const toggleFlag = useCallback(
|
||
() =>
|
||
setState((s) => ({
|
||
...s,
|
||
flags: { ...s.flags, [q.id]: !s.flags[q.id] },
|
||
})),
|
||
[q.id],
|
||
);
|
||
|
||
const updateNotes = (value: string) =>
|
||
setState((s) => ({ ...s, notes: value.slice(0, 1000) }));
|
||
|
||
const checkAnswer = () => {
|
||
setState((s) => ({
|
||
...s,
|
||
revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true },
|
||
}));
|
||
setRevealed(true);
|
||
};
|
||
|
||
const [now, setNow] = useState(Date.now());
|
||
useEffect(() => {
|
||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(t);
|
||
}, []);
|
||
|
||
const elapsedMs = now - startedAt;
|
||
const remainingMs = exam.timeLimitMinutes
|
||
? exam.timeLimitMinutes * 60_000 - elapsedMs
|
||
: null;
|
||
const timeUrgent = remainingMs != null && remainingMs < 60_000;
|
||
|
||
useEffect(() => {
|
||
if (remainingMs != null && remainingMs <= 0) onSubmit();
|
||
}, [remainingMs, onSubmit]);
|
||
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
const tag = (e.target as HTMLElement).tagName;
|
||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||
if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1);
|
||
if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1);
|
||
if (e.key === "f") toggleFlag();
|
||
if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) {
|
||
const ci = Number.parseInt(e.key, 10) - 1;
|
||
const choices = q.choices;
|
||
if (choices[ci])
|
||
setState((s) => ({
|
||
...s,
|
||
answers: { ...s.answers, [q.id]: choices[ci].id },
|
||
}));
|
||
}
|
||
};
|
||
globalThis.addEventListener("keydown", handler);
|
||
return () => globalThis.removeEventListener("keydown", handler);
|
||
}, [currentIdx, q, goTo, toggleFlag, setState]);
|
||
|
||
const graded = feedbackMode === "immediate" && revealed;
|
||
const answered =
|
||
answers[q.id] != null &&
|
||
(!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0);
|
||
const result = graded ? gradeQuestion(q, answers[q.id]) : null;
|
||
|
||
const navStatus = (qq: Question): string => {
|
||
if (feedbackMode === "immediate" && state.revealed?.[qq.id]) {
|
||
const r = gradeQuestion(qq, answers[qq.id]);
|
||
if (r.status === "correct") return "correct";
|
||
if (r.status === "wrong") return "wrong";
|
||
// fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay)
|
||
}
|
||
if (flags[qq.id]) return "flagged";
|
||
const a = answers[qq.id];
|
||
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0))
|
||
return "answered";
|
||
return "";
|
||
};
|
||
|
||
const answeredCount = exam.questions.filter((qq) => {
|
||
const st = navStatus(qq);
|
||
return st === "answered" || st === "correct" || st === "wrong";
|
||
}).length;
|
||
|
||
const totalPossible = exam.questions.reduce(
|
||
(acc, qq) => acc + (qq.points ?? 1),
|
||
0,
|
||
);
|
||
let answerText = "";
|
||
if (!Array.isArray(q.answer) && typeof q.answer === "string") {
|
||
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer;
|
||
} else if (Array.isArray(q.answer)) {
|
||
answerText = q.answer
|
||
.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id)
|
||
.join(", ");
|
||
}
|
||
|
||
return (
|
||
<div className="exam-shell">
|
||
<aside className="sidebar">
|
||
<h2 className="sidebar-title">{exam.title}</h2>
|
||
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
|
||
|
||
<div className="sidebar-section-label">{t('exam.questionsLabel')}</div>
|
||
<div className="nav-list">
|
||
{exam.questions.map((qq, i) => {
|
||
const st = navStatus(qq);
|
||
return (
|
||
<button
|
||
key={qq.id}
|
||
className={`nav-item${i === currentIdx ? " active" : ""}`}
|
||
onClick={() => goTo(i)}
|
||
>
|
||
<span className="nav-item-num">
|
||
{String(i + 1).padStart(2, "0")}
|
||
</span>
|
||
<span className="nav-item-title">
|
||
{qq.category ? `${qq.category} · ` : ""}
|
||
{qq.type.replace("-", " ")}
|
||
</span>
|
||
<span className={`nav-dot${st ? " " + st : ""}`} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="sidebar-section-label">{t('exam.progressLabel')}</div>
|
||
<div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
|
||
{t('exam.progressText', { answered: answeredCount, total })}
|
||
</div>
|
||
<div className="progress-bar">
|
||
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: 32,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<button className="btn btn-primary" onClick={onSubmit}>
|
||
{t('exam.submitExam')}
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||
{t('exam.exitToHome')}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="main">
|
||
<header className="question-header">
|
||
<span className="q-index">
|
||
{t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
|
||
</span>
|
||
{q.category && <span className="q-category">{q.category}</span>}
|
||
<span className="q-points">
|
||
{q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
|
||
</span>
|
||
</header>
|
||
|
||
<Tex tag="div" className="q-prompt" markdown>
|
||
{q.prompt}
|
||
</Tex>
|
||
<QuestionMedia q={q} />
|
||
<QuestionRenderer
|
||
key={q.id}
|
||
q={q}
|
||
value={answers[q.id]}
|
||
onChange={setAnswer}
|
||
locked={graded && q.type !== "essay"}
|
||
graded={graded}
|
||
/>
|
||
|
||
{(currentHint || hintError) && (
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
padding: 12,
|
||
borderLeft: '3px solid var(--accent)',
|
||
background: 'var(--paper)',
|
||
fontSize: 13,
|
||
color: 'var(--ink-2)',
|
||
borderRadius: 4,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
|
||
{t('exam.hintLabel')}
|
||
</div>
|
||
{currentHint && <Tex>{currentHint}</Tex>}
|
||
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
|
||
</div>
|
||
)}
|
||
|
||
{graded && result && q.type !== "essay" && (
|
||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||
<div className="feedback-label">
|
||
{result.correct ? t('exam.correct') : t('exam.notQuite')}
|
||
</div>
|
||
<div className="feedback-body">
|
||
{!result.correct &&
|
||
q.answer &&
|
||
q.type !== "match" &&
|
||
q.type !== "order" && (
|
||
<p>
|
||
<strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
|
||
</p>
|
||
)}
|
||
{q.explanation && (
|
||
<p>
|
||
<Tex>{q.explanation}</Tex>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<footer className="exam-footer">
|
||
<button
|
||
className="btn"
|
||
onClick={() => goTo(currentIdx - 1)}
|
||
disabled={currentIdx === 0}
|
||
>
|
||
{t('exam.previous')}
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||
</button>
|
||
{!revealed && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={() => void requestHint()}
|
||
disabled={hintLoading || !!currentHint}
|
||
title={t('exam.hintTitle')}
|
||
>
|
||
{hintLoading
|
||
? t('exam.hintLoading')
|
||
: currentHint
|
||
? t('exam.hintShown')
|
||
: t('exam.needHint')}
|
||
</button>
|
||
)}
|
||
<span className="spacer" />
|
||
{feedbackMode === "immediate" &&
|
||
!revealed &&
|
||
q.type !== "essay" &&
|
||
answered && (
|
||
<button className="btn btn-primary" onClick={checkAnswer}>
|
||
{t('exam.checkAnswer')}
|
||
</button>
|
||
)}
|
||
{currentIdx < total - 1 ? (
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => goTo(currentIdx + 1)}
|
||
>
|
||
{t('exam.next')}
|
||
</button>
|
||
) : (
|
||
<button className="btn btn-primary" onClick={onSubmit}>
|
||
{t('exam.submitExam')}
|
||
</button>
|
||
)}
|
||
</footer>
|
||
|
||
<div
|
||
style={{
|
||
marginTop: 48,
|
||
fontSize: 11.5,
|
||
color: "var(--ink-4)",
|
||
display: "flex",
|
||
gap: 16,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span>
|
||
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
|
||
{t('exam.navigateHint')}
|
||
</span>
|
||
<span>
|
||
<span className="kbd">F</span> {t('exam.flagHint')}
|
||
</span>
|
||
{q.type === "multiple-choice" && q.choices && (
|
||
<span>
|
||
<span className="kbd">1</span>–
|
||
<span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<aside className="meta-rail">
|
||
<div className="meta-block">
|
||
<div className="meta-label">{t('exam.elapsed')}</div>
|
||
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
|
||
</div>
|
||
{remainingMs != null && (
|
||
<div className="meta-block">
|
||
<div className="meta-label">{t('exam.remaining')}</div>
|
||
<div
|
||
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
|
||
>
|
||
{formatTime(Math.max(0, remainingMs))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="meta-block">
|
||
<div className="meta-label">{t('exam.pointsPossible')}</div>
|
||
<div className="meta-value mono">{totalPossible}</div>
|
||
</div>
|
||
<div className="meta-block">
|
||
<div className="meta-label">{t('exam.mode')}</div>
|
||
<div className="meta-value mono" style={{ fontSize: 13 }}>
|
||
{feedbackMode === "immediate"
|
||
? t('exam.immediateFeedback')
|
||
: t('exam.gradeAtEnd')}
|
||
</div>
|
||
</div>
|
||
<div className="meta-block notes-block">
|
||
<div className="meta-label">{t('exam.notes')}</div>
|
||
<textarea
|
||
className="textarea notes-pad"
|
||
value={notes}
|
||
onChange={(e) => updateNotes(e.target.value)}
|
||
placeholder={t('exam.notesPlaceholder')}
|
||
maxLength={1000}
|
||
aria-label={t('exam.notes')}
|
||
/>
|
||
<div className="notes-count mono">{notes.length}/1000</div>
|
||
</div>
|
||
{exam.author && (
|
||
<div className="meta-block">
|
||
<div className="meta-label">{t('exam.author')}</div>
|
||
<div style={{ fontSize: 14 }}>{exam.author}</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="meta-block" style={{ marginTop: 'auto' }}>
|
||
<div className="meta-label">{t('tweaks.feedback')}</div>
|
||
<div className="tweak-control">
|
||
<button
|
||
className={`tweak-seg${feedbackMode === 'immediate' ? ' active' : ''}`}
|
||
onClick={() =>
|
||
setState((s) => ({ ...s, feedbackMode: 'immediate' }))
|
||
}
|
||
>
|
||
{t('tweaks.immediate')}
|
||
</button>
|
||
<button
|
||
className={`tweak-seg${feedbackMode === 'end' ? ' active' : ''}`}
|
||
onClick={() =>
|
||
setState((s) => ({ ...s, feedbackMode: 'end' }))
|
||
}
|
||
>
|
||
{t('tweaks.end')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|