Add SVG icons and TypeScript configuration files

- Introduced a new favicon.svg file for the application.
- Added icons.svg containing various SVG symbols for UI components.
- Created tsconfig.app.json for frontend TypeScript configuration with ES2023 target and bundler mode.
- Established tsconfig.json to reference app and node configurations.
- Implemented tsconfig.node.json for Node.js specific TypeScript settings.
This commit is contained in:
LaurensBartelds committed 2026-04-21 22:19:06 +02:00
1 parent 8420e6cae0
commit be69db04a5
21 files changed
+6872

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
/* global React, ReactDOM */
// ==============================================================
// app.jsx — main controller + Tweaks
// ==============================================================
var { useState, useEffect, useCallback } = React;
const TWEAKS = /*EDITMODE-BEGIN*/{
"theme": "light",
"fontSize": "medium",
"questionLayout": "standard",
"feedbackMode": "auto"
}/*EDITMODE-END*/;
const App = () => {
const [screen, setScreen] = useState('landing'); // landing | exam | results | review
const [exam, setExam] = useState(null);
const [state, setState] = useState(null);
const [history, setHistory] = useState(() => window.loadHistory());
const [tweaks, setTweaks] = useState(TWEAKS);
const [tweaksOpen, setTweaksOpen] = useState(false);
// Apply theme/font tweaks
useEffect(() => {
document.documentElement.dataset.theme = tweaks.theme;
const map = { small: '14px', medium: '15px', large: '17px' };
document.body.style.fontSize = map[tweaks.fontSize] || '15px';
document.documentElement.dataset.layout = tweaks.questionLayout;
}, [tweaks]);
// Persist state on change during exam
useEffect(() => {
if (screen === 'exam' && exam && state) {
window.saveState({ exam, state });
}
}, [exam, state, screen]);
// Edit mode protocol
useEffect(() => {
const handler = (e) => {
if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
const updateTweak = (key, value) => {
setTweaks((t) => {
const next = { ...t, [key]: value };
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: value } }, '*');
return next;
});
};
const loadExam = (data) => {
const finalFeedbackMode = tweaks.feedbackMode === 'auto' ? (data.feedbackMode || 'end') : tweaks.feedbackMode;
const initialState = {
currentIdx: 0,
answers: {},
flags: {},
revealed: {},
startedAt: Date.now(),
feedbackMode: finalFeedbackMode,
};
setExam(data);
setState(initialState);
setScreen('exam');
};
const resume = () => {
const saved = window.loadState();
if (saved?.exam && saved?.state) {
setExam(saved.exam);
setState(saved.state);
setScreen('exam');
}
};
const submit = useCallback(() => {
if (!exam || !state) return;
const completedAt = Date.now();
const elapsedMs = completedAt - state.startedAt;
const graded = exam.questions.map((q) => window.gradeQuestion(q, state.answers[q.id]));
const gradedCount = graded.filter((r) => r.status !== 'ungraded').length;
const correctCount = graded.filter((r) => r.correct).length;
const totalPoints = exam.questions.reduce((a, q) => a + (q.points || 1), 0);
const scorePoints = graded.reduce((a, r) => a + r.points, 0);
const historyEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: exam.title,
completedAt,
elapsedMs,
gradedCount,
correctCount,
totalPoints,
scorePoints,
passingScore: exam.passingScore || 60,
exam, // full snapshot so we can re-review
state: { ...state, completedAt },
};
window.appendHistory(historyEntry);
setHistory(window.loadHistory());
window.clearState();
setState((s) => ({ ...s, completedAt }));
setScreen('results');
}, [exam, state]);
const exit = () => {
setScreen('landing');
setExam(null);
setState(null);
};
const restart = () => {
if (!exam) return;
loadExam(exam);
};
// Replay a past attempt
window.__replayAttempt = (entry) => {
setExam(entry.exam);
setState(entry.state);
setScreen('review');
};
const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) {
window.clearHistory();
setHistory([]);
}
};
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span>BrainWave</span>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
</div>
<div className="topbar-meta">
{screen === 'exam' && exam && (
<>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
</>
)}
</div>
</header>
{screen === 'landing' && (
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
/>
)}
{screen === 'exam' && exam && state && (
<ExamRunner
exam={exam}
state={state}
setState={setState}
onSubmit={submit}
onExit={exit}
/>
)}
{(screen === 'results' || screen === 'review') && exam && state && (
<Results
exam={exam}
state={state}
onRestart={restart}
onExit={exit}
reviewOnly={screen === 'review'}
/>
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
</div>
);
};
// ---------- Tweaks Panel ----------
const TweaksPanel = ({ tweaks, update, onClose }) => {
const Seg = ({ tkey, options }) => (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg ${tweaks[tkey] === opt.value ? 'active' : ''}`}
onClick={() => update(tkey, opt.value)}
>
{opt.label}
</button>
))}
</div>
);
return (
<div className="tweaks-panel">
<div className="tweaks-header">
<span>Tweaks</span>
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label">Theme</div>
<Seg tkey="theme" options={[{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]} />
</div>
<div className="tweak-row">
<div className="tweak-label">Text size</div>
<Seg tkey="fontSize" options={[{ value: 'small', label: 'S' }, { value: 'medium', label: 'M' }, { value: 'large', label: 'L' }]} />
</div>
<div className="tweak-row">
<div className="tweak-label">Feedback</div>
<Seg tkey="feedbackMode" options={[
{ value: 'auto', label: 'Auto' },
{ value: 'immediate', label: 'Immediate' },
{ value: 'end', label: 'End' },
]} />
</div>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,386 @@
/* global React */
// ==============================================================
// questions.jsx — renderers for every question type
// ==============================================================
var { useState, useEffect, useRef, useMemo } = React;
// ---------- Media blocks (image, code, table, audio) ----------
const QuestionMedia = ({ q }) => (
<>
{q.image && (
<figure className="q-media">
<img src={q.image.src} alt={q.image.alt || ''} />
{q.image.caption && <figcaption className="q-media-caption">{q.image.caption}</figcaption>}
</figure>
)}
{q.audio && (
<div className="q-audio">
<audio controls src={q.audio.src}>Your browser does not support audio.</audio>
</div>
)}
{q.code && (
<pre
className="q-code"
dangerouslySetInnerHTML={{ __html: window.highlightCode(q.code.content, q.code.language) }}
/>
)}
{q.table && (
<table className="q-table">
<thead>
<tr>{q.table.headers.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{q.table.rows.map((row, i) => (
<tr key={i}>{row.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
))}
</tbody>
</table>
)}
{q.svg && <div className="q-media" dangerouslySetInnerHTML={{ __html: q.svg }} />}
</>
);
// ---------- Multiple Choice ----------
const MultipleChoice = ({ q, value, onChange, locked, graded }) => (
<div className="choices" role="radiogroup">
{q.choices.map((c, i) => {
const isSelected = value === c.id;
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (c.id === q.answer) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button
key={c.id}
className={cls}
onClick={() => !locked && onChange(c.id)}
disabled={locked}
role="radio"
aria-checked={isSelected}
>
<span className="choice-marker" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
// ---------- Multiple Select ----------
const MultipleSelect = ({ q, value = [], onChange, locked, graded }) => {
const toggle = (id) => {
if (locked) return;
onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id]);
};
const answerSet = new Set(q.answer || []);
return (
<div className="choices">
{q.choices.map((c, i) => {
const isSelected = value.includes(c.id);
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (answerSet.has(c.id)) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button key={c.id} className={cls} onClick={() => toggle(c.id)} disabled={locked}>
<span className="choice-marker square" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
};
// ---------- Short Answer ----------
const ShortAnswer = ({ q, value = '', onChange, locked }) => (
<div className="answer-input-wrap">
<input
className="input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Type your answer…"
disabled={locked}
autoComplete="off"
spellCheck={q.type === 'translation'}
/>
{q.type === 'translation' && q.targetLanguage && (
<div className="faint mono" style={{ fontSize: 11 }}>
Answer in {q.targetLanguage}
</div>
)}
</div>
);
// ---------- Math Input ----------
const MathInput = ({ q, value = '', onChange, locked }) => {
const [preview, setPreview] = useState('');
useEffect(() => {
if (!value) { setPreview(''); return; }
try {
setPreview(window.katex.renderToString(value, { throwOnError: false, displayMode: false }));
} catch {
setPreview('');
}
}, [value]);
return (
<div className="answer-input-wrap">
<input
className="input mono"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="LaTeX, e.g. 2x\ln x + x"
disabled={locked}
autoComplete="off"
spellCheck={false}
/>
<div className="math-preview">
<span className="math-preview-label">Preview</span>
{preview
? <span dangerouslySetInnerHTML={{ __html: preview }} />
: <span className="faint" style={{ fontSize: 13 }}>Live LaTeX preview appears here</span>}
</div>
</div>
);
};
// ---------- Essay ----------
const Essay = ({ q, value = {}, onChange, locked }) => {
const text = typeof value === 'string' ? value : (value.text || '');
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
const minOK = !q.minWords || wordCount >= q.minWords;
return (
<div className="answer-input-wrap" style={{ maxWidth: 720 }}>
<textarea
className="textarea"
value={text}
onChange={(e) => onChange({ ...value, text: e.target.value })}
placeholder="Write your response…"
disabled={locked}
rows={10}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }} className="mono faint">
<span>{wordCount} words {q.minWords && <span style={{ color: minOK ? 'var(--ink-3)' : 'var(--wrong)' }}>· min {q.minWords}</span>}</span>
<span>Self-graded · see rubric on review</span>
</div>
</div>
);
};
// ---------- Match ----------
const Match = ({ q, value = {}, onChange, locked, graded }) => {
const [activeLeft, setActiveLeft] = useState(null);
const reverseMap = useMemo(() => {
const m = {};
Object.entries(value).forEach(([l, r]) => { m[r] = l; });
return m;
}, [value]);
const handleRight = (rightId) => {
if (locked) return;
if (activeLeft) {
const next = { ...value };
// clear any existing pairing for this left or right
Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k]; });
next[activeLeft] = rightId;
onChange(next);
setActiveLeft(null);
} else if (reverseMap[rightId]) {
// click right to clear its pairing
const next = { ...value };
delete next[reverseMap[rightId]];
onChange(next);
}
};
const handleLeft = (leftId) => {
if (locked) return;
if (value[leftId]) {
// click a paired left → clear it
const next = { ...value };
delete next[leftId];
onChange(next);
return;
}
setActiveLeft(activeLeft === leftId ? null : leftId);
};
const badge = (id, fromLeft) => {
if (fromLeft) {
const r = value[id];
if (!r) return null;
const idx = q.right.findIndex((x) => x.id === r);
return <span className="match-pair-badge">{String.fromCharCode(65 + idx)}</span>;
}
const l = reverseMap[id];
if (!l) return null;
const idx = q.left.findIndex((x) => x.id === l);
return <span className="match-pair-badge">{idx + 1}</span>;
};
const statusCls = (leftId, rightId) => {
if (!graded) return '';
const correctPair = q.answer[leftId];
if (correctPair && value[leftId] === correctPair) return ' correct';
if (value[leftId]) return ' wrong';
return '';
};
return (
<div className="match-grid">
<div className="match-col">
<div className="match-col-label">Items</div>
{q.left.map((item, i) => {
let cls = 'match-item';
if (activeLeft === item.id) cls += ' active';
else if (value[item.id]) cls += ' paired';
cls += statusCls(item.id);
return (
<button key={item.id} className={cls} onClick={() => handleLeft(item.id)} disabled={locked}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, true)}
</button>
);
})}
</div>
<div className="match-col">
<div className="match-col-label">Matches</div>
{q.right.map((item, i) => {
let cls = 'match-item';
if (reverseMap[item.id]) cls += ' paired';
// for graded: color right item based on whether the left it's paired with is correct
if (graded && reverseMap[item.id]) {
const l = reverseMap[item.id];
if (q.answer[l] === item.id) cls += ' correct';
else cls += ' wrong';
}
return (
<button key={item.id} className={cls} onClick={() => handleRight(item.id)} disabled={locked || (!activeLeft && !reverseMap[item.id])}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, false)}
</button>
);
})}
</div>
</div>
);
};
// ---------- Order (drag to reorder) ----------
const OrderQuestion = ({ q, value, onChange, locked, graded }) => {
// initialize value as shuffled item ids on first render
const initial = useMemo(() => {
if (Array.isArray(value) && value.length === q.items.length) return value;
const ids = q.items.map((i) => i.id);
// deterministic shuffle using question id as seed
const seed = q.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const out = [...ids];
for (let i = out.length - 1; i > 0; i--) {
const j = (seed * (i + 7)) % (i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}, [q.id]);
useEffect(() => {
if (!Array.isArray(value) || value.length !== q.items.length) {
onChange(initial);
}
}, []);
const list = Array.isArray(value) && value.length === q.items.length ? value : initial;
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const move = (from, to) => {
if (from === to || from < 0 || to < 0) return;
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
};
const onDragStart = (idx) => (e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (idx) => (e) => {
e.preventDefault();
setOverIdx(idx);
};
const onDrop = (idx) => (e) => {
e.preventDefault();
if (dragIdx != null && dragIdx !== idx) move(dragIdx, idx);
setDragIdx(null); setOverIdx(null);
};
const itemById = (id) => q.items.find((i) => i.id === id);
return (
<div className="order-list">
{list.map((id, idx) => {
let cls = 'order-item';
if (dragIdx === idx) cls += ' dragging';
if (overIdx === idx && dragIdx !== idx) cls += dragIdx > idx ? ' drop-before' : ' drop-after';
if (graded) {
cls += q.answer[idx] === id ? ' correct' : ' wrong';
}
return (
<div
key={id}
className={cls}
draggable={!locked}
onDragStart={onDragStart(idx)}
onDragOver={onDragOver(idx)}
onDrop={onDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
>
<span className="order-handle">⋮⋮</span>
<span className="order-num">{idx + 1}</span>
<span className="order-text"><window.Tex>{itemById(id)?.text || ''}</window.Tex></span>
{!locked && (
<div style={{ display: 'flex', gap: 2 }}>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx - 1)} disabled={idx === 0} aria-label="Move up">↑</button>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx + 1)} disabled={idx === list.length - 1} aria-label="Move down">↓</button>
</div>
)}
</div>
);
})}
</div>
);
};
// ---------- Dispatcher ----------
const QuestionRenderer = ({ q, value, onChange, locked, graded }) => {
const props = { q, value, onChange, locked, graded };
switch (q.type) {
case 'multiple-choice': return <MultipleChoice {...props} />;
case 'multiple-select': return <MultipleSelect {...props} />;
case 'short-answer':
case 'translation': return <ShortAnswer {...props} />;
case 'math-input': return <MathInput {...props} />;
case 'essay': return <Essay {...props} />;
case 'match': return <Match {...props} />;
case 'order': return <OrderQuestion {...props} />;
default:
return <div className="muted">Unsupported question type: <code>{q.type}</code></div>;
}
};
Object.assign(window, { QuestionMedia, QuestionRenderer });
@@ -0,0 +1,458 @@
/* global React */
// ==============================================================
// screens.jsx — Landing, Exam, Results
// ==============================================================
var { useState, useEffect, useRef, useMemo, useCallback } = React;
// ---------- LANDING ----------
const Landing = ({ onLoadExam, history, onResume, onClearHistory }) => {
const [err, setErr] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef(null);
const handleFile = async (file) => {
setErr(null);
try {
const text = await file.text();
const data = JSON.parse(text);
const msg = window.validateExam(data);
if (msg) throw new Error(msg);
onLoadExam(data);
} catch (e) {
setErr(`Could not load: ${e.message}`);
}
};
const loadSample = async (path) => {
setErr(null);
try {
const r = await fetch(path);
const data = await r.json();
onLoadExam(data);
} catch (e) {
setErr(`Could not load sample: ${e.message}`);
}
};
const samples = [
{ path: 'samples/calculus-101.json', title: 'Calculus I — Midterm Practice', meta: '7 questions · 45 min · LaTeX math', mark: '∫' },
{ path: 'samples/spanish-a2.json', title: 'Spanish A2 — Vocabulary & Grammar', meta: '6 questions · 20 min · Translation', mark: 'ñ' },
{ path: 'samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
];
const resumable = window.loadState();
return (
<div className="landing">
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
<p className="landing-sub">
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
</p>
<div className="landing-grid">
<div>
<div
className={`dropzone ${dragOver ? 'drag-over' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) handleFile(f);
}}
>
<div className="dropzone-icon" />
<h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
style={{ display: 'none' }}
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
</div>
{err && <div style={{ marginTop: 12, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>{err}</div>}
{resumable && (
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>{resumable.exam.title} · {Object.keys(resumable.answers || {}).length}/{resumable.exam.questions.length} answered</div>
</div>
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button>
</div>
)}
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div>
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
</div>
</div>
<div className="samples">
<h4>Try a sample</h4>
{samples.map((s) => (
<div key={s.path} className="sample" onClick={() => loadSample(s.path)}>
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
<div className="sample-body">
<div className="sample-title">{s.title}</div>
<div className="sample-meta">{s.meta}</div>
</div>
<div className="sample-arrow">→</div>
</div>
))}
<a
href="docs/schema.md"
target="_blank"
style={{ display: 'block', marginTop: 16, fontSize: 12.5, color: 'var(--ink-3)', textDecoration: 'none', borderBottom: '1px solid var(--line-2)', paddingBottom: 2, width: 'fit-content' }}
>
View JSON schema →
</a>
</div>
</div>
{history.length > 0 && (
<div className="history">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>Past attempts</h4>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button>
</div>
{history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0;
const passed = pct >= (h.passingScore || 60);
return (
<div key={h.id} className="history-row">
<div>
<div className="history-title">{h.title}</div>
<div className="muted" style={{ fontSize: 12 }}>{window.formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct</div>
</div>
<div className="mono muted" style={{ fontSize: 12 }}>{window.formatTime(h.elapsedMs)}</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-ghost btn-sm" onClick={() => window.__replayAttempt?.(h)}>Review</button>
</div>
);
})}
</div>
)}
</div>
);
};
// ---------- EXAM (runner) ----------
const ExamRunner = ({ exam, state, setState, onSubmit, onExit }) => {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
const q = exam.questions[currentIdx];
const total = exam.questions.length;
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const setAnswer = (val) => {
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
};
const goTo = (i) => setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) }));
const toggleFlag = () => setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } }));
const checkAnswer = () => {
setState((s) => ({ ...s, revealed: { ...(s.revealed || {}), [q.id]: true } }));
setRevealed(true);
};
// Timer
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]);
// keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === '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') {
const idx = parseInt(e.key, 10) - 1;
if (q.choices[idx]) setAnswer(q.choices[idx].id);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [currentIdx, q]);
const graded = feedbackMode === 'immediate' && revealed;
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || answers[q.id].length > 0);
const result = graded ? window.gradeQuestion(q, answers[q.id]) : null;
// Navigator status
const navStatus = (qq) => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
const r = window.gradeQuestion(qq, answers[qq.id]);
if (r.status === 'correct') return 'correct';
if (r.status === 'wrong') return 'wrong';
}
if (flags[qq.id]) return 'flagged';
if (answers[qq.id] != null && (!Array.isArray(answers[qq.id]) || answers[qq.id].length > 0)) return 'answered';
return '';
};
const answeredCount = exam.questions.filter((qq) => navStatus(qq) === 'answered' || navStatus(qq) === 'correct' || navStatus(qq) === 'wrong').length;
return (
<div className="exam-shell">
{/* Sidebar */}
<aside className="sidebar">
<h2 className="sidebar-title">{exam.title}</h2>
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
<div className="sidebar-section-label">Questions</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}`} />
</button>
);
})}
</div>
<div className="sidebar-section-label">Progress</div>
<div className="mono" style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{answeredCount} of {total} answered
</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}>Submit exam</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>Exit to home</button>
</div>
</aside>
{/* Main */}
<main className="main">
<header className="question-header">
<span className="q-index">Question {String(currentIdx + 1).padStart(2, '0')} / {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 ? 'pt' : 'pts'}</span>
</header>
<window.Tex tag="div" className="q-prompt">{q.prompt}</window.Tex>
<window.QuestionMedia q={q} />
<window.QuestionRenderer
q={q}
value={answers[q.id]}
onChange={setAnswer}
locked={graded && q.type !== 'essay'}
graded={graded}
/>
{graded && result && q.type !== 'essay' && (
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}>
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div>
<div className="feedback-body">
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
<p><strong>Answer:</strong> <window.Tex>{Array.isArray(q.answer) ? q.answer.map(id => q.choices?.find(c => c.id === id)?.text || id).join(', ') : (q.choices?.find(c => c.id === q.answer)?.text || q.answer)}</window.Tex></p>
)}
{q.explanation && <p><window.Tex>{q.explanation}</window.Tex></p>}
</div>
</div>
)}
<footer className="exam-footer">
<button className="btn" onClick={() => goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous</button>
<button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
</button>
<span className="spacer" />
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && (
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button>
)}
{currentIdx < total - 1 ? (
<button className="btn btn-primary" onClick={() => goTo(currentIdx + 1)}>Next →</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</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> navigate</span>
<span><span className="kbd">F</span> flag</span>
{q.type === 'multiple-choice' && <span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span>}
</div>
</main>
{/* Meta rail */}
<aside className="meta-rail">
<div className="meta-block">
<div className="meta-label">Elapsed</div>
<div className="meta-value mono">{window.formatTime(elapsedMs)}</div>
</div>
{remainingMs != null && (
<div className="meta-block">
<div className="meta-label">Remaining</div>
<div className={`meta-value mono ${timeUrgent ? 'timer-urgent' : ''}`}>{window.formatTime(Math.max(0, remainingMs))}</div>
</div>
)}
<div className="meta-block">
<div className="meta-label">Points possible</div>
<div className="meta-value mono">{exam.questions.reduce((a, q) => a + (q.points || 1), 0)}</div>
</div>
<div className="meta-block">
<div className="meta-label">Mode</div>
<div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>
<div style={{ fontSize: 14 }}>{exam.author}</div>
</div>
)}
</aside>
</div>
);
};
// ---------- RESULTS ----------
const Results = ({ exam, state, onRestart, onExit, reviewOnly }) => {
const { answers, startedAt, completedAt } = state;
const elapsedMs = (completedAt || Date.now()) - startedAt;
const graded = exam.questions.map((q) => ({
q,
userAnswer: answers[q.id],
result: window.gradeQuestion(q, answers[q.id]),
}));
const totalPoints = exam.questions.reduce((a, q) => a + (q.points || 1), 0);
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0);
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length;
const correctCount = graded.filter((g) => g.result.correct).length;
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0;
const passing = exam.passingScore || 60;
const passed = pct >= passing;
// Categories breakdown
const byCat = {};
graded.forEach((g) => {
const cat = g.q.category || 'Uncategorized';
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 };
byCat[cat].possible += g.q.points || 1;
byCat[cat].earned += g.result.points;
byCat[cat].total += 1;
if (g.result.correct) byCat[cat].correct += 1;
});
const [openIdx, setOpenIdx] = useState(null);
return (
<div className="results">
<div className="results-eyebrow">
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
</div>
<h1 className="results-title">
You scored <em>{pct}%</em><br />
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
{passed ? `Above the ${passing}% passing mark.` : `Below the ${passing}% passing mark. Keep practicing.`}
</span>
</h1>
<div className="score-grid">
<div className="score-cell">
<div className="score-cell-label">Points</div>
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Correct</div>
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Time</div>
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{window.formatTime(elapsedMs)}</div>
</div>
</div>
{Object.keys(byCat).length > 1 && (
<div className="category-breakdown">
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>By category</div>
{Object.entries(byCat).map(([cat, c]) => {
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0;
return (
<div key={cat} className="category-row">
<div style={{ fontWeight: 500 }}>{cat}</div>
<div className="category-bar"><div style={{ width: `${catPct}%` }} /></div>
<div className="category-score">{catPct}%</div>
</div>
);
})}
</div>
)}
<h2 className="review-heading">Review</h2>
{graded.map((g, i) => (
<div key={g.q.id} className={`review-item ${openIdx === i ? 'open' : ''}`}>
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
<span className="review-title"><window.Tex>{g.q.prompt.replace(/\$\$([^$]+)\$\$/g, '[math]').replace(/\$([^$]+)\$/g, '[math]').slice(0, 90)}</window.Tex>{g.q.prompt.length > 90 ? '…' : ''}</span>
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
</div>
{openIdx === i && (
<div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<window.Tex>{g.q.prompt}</window.Tex>
</div>
<window.QuestionMedia q={g.q} />
<window.QuestionRenderer q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">Explanation</div>
<div className="feedback-body"><window.Tex>{g.q.explanation}</window.Tex></div>
</div>
)}
{g.q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><window.Tex>{r}</window.Tex></li>)}
</ul>
</div>
)}
</div>
)}
</div>
))}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
<button className="btn" onClick={onExit}>Back to home</button>
</div>
</div>
);
};
Object.assign(window, { Landing, ExamRunner, Results });
+234
View File
@@ -0,0 +1,234 @@
/* global window */
// ==============================================================
// utils.js — pure helpers: normalization, grading, formatting
// ==============================================================
const normalizeString = (s, opts = {}) => {
if (s == null) return '';
let out = String(s);
if (!opts.caseSensitive) out = out.toLowerCase();
if (opts.ignoreAccents !== false) {
out = out.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
if (opts.ignorePunctuation !== false) {
out = out.replace(/[.,/#!$%^&*;:{}=\-_`~()¿?¡!'"]/g, '');
}
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const normalizeLatex = (s) => {
if (s == null) return '';
return String(s)
.replace(/\s+/g, '')
.replace(/\\left|\\right/g, '')
.replace(/\\,|\\;|\\:|\\!|\\ /g, '')
.replace(/\{([^{}])\}/g, '$1') // strip braces around single chars
.replace(/\\cdot/g, '*')
.replace(/\\times/g, '*')
.replace(/\\ln/g, 'ln')
.replace(/\\log/g, 'log')
.toLowerCase();
};
const arraysEqualUnordered = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
return sa.every((v, i) => v === sb[i]);
};
const arraysEqualOrdered = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
};
// ---- grading ----
const gradeQuestion = (q, userAnswer) => {
if (userAnswer == null) return { status: 'unanswered', correct: false, points: 0 };
switch (q.type) {
case 'multiple-choice': {
const ok = userAnswer === q.answer;
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'multiple-select': {
const ok = arraysEqualUnordered(userAnswer || [], q.answer || []);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'short-answer':
case 'translation': {
const opts = {
caseSensitive: q.caseSensitive === true,
ignorePunctuation: q.ignorePunctuation !== false,
ignoreAccents: q.ignoreAccents !== false,
};
const target = normalizeString(userAnswer, opts);
const accepted = [q.answer, ...(q.acceptedForms || [])]
.filter(Boolean)
.map((a) => normalizeString(a, opts));
const ok = accepted.includes(target);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'math-input': {
const target = normalizeLatex(userAnswer);
const accepted = [q.answer, ...(q.acceptedForms || [])].filter(Boolean).map(normalizeLatex);
const ok = accepted.includes(target);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'match': {
const answer = q.answer || {};
const given = userAnswer || {};
const keys = Object.keys(answer);
if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 };
const allOk = keys.every((k) => given[k] === answer[k]);
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? (q.points || 1) : 0 };
}
case 'order': {
const ok = arraysEqualOrdered(userAnswer || [], q.answer || []);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'essay': {
// self-graded; default to ungraded
if (userAnswer && userAnswer.selfGrade != null) {
const g = userAnswer.selfGrade;
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round((q.points || 1) * g) };
}
return { status: 'ungraded', correct: false, points: 0 };
}
default:
return { status: 'unanswered', correct: false, points: 0 };
}
};
// ---- time formatting ----
const formatTime = (ms) => {
if (ms == null || ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
};
const formatDate = (ts) => {
const d = new Date(ts);
const now = new Date();
const diff = now - d;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
// ---- LaTeX rendering (safe wrapper around KaTeX) ----
// Renders inline $...$ and display $$...$$ within a plain string.
const renderLatex = (text) => {
if (!text) return '';
if (!window.katex) return escapeHtml(text);
const parts = [];
let rest = String(text);
// First handle $$...$$ (display)
const displayRe = /\$\$([\s\S]+?)\$\$/g;
rest = rest.replace(displayRe, (_, tex) => {
try {
return `<span class="katex-display-wrap">${window.katex.renderToString(tex, { displayMode: true, throwOnError: false })}</span>`;
} catch {
return `<code>${escapeHtml(tex)}</code>`;
}
});
// Then $...$ (inline)
const inlineRe = /\$([^$\n]+?)\$/g;
rest = rest.replace(inlineRe, (_, tex) => {
try {
return window.katex.renderToString(tex, { displayMode: false, throwOnError: false });
} catch {
return `<code>${escapeHtml(tex)}</code>`;
}
});
return rest;
};
const escapeHtml = (s) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Component that renders a string with LaTeX + preserves newlines as <br>
const Tex = ({ children, tag: Tag = 'span', className }) => {
const html = String(children || '').split('\n').map(renderLatex).join('<br/>');
return React.createElement(Tag, {
className,
dangerouslySetInnerHTML: { __html: html },
});
};
// ---- simple syntax highlighter (for code blocks) ----
const highlightCode = (code, lang) => {
const lines = escapeHtml(code);
const kw = /\b(function|return|if|else|const|let|var|for|while|import|export|from|class|new|this|def|print|in|not|and|or|True|False|None|null|undefined|true|false)\b/g;
const str = /(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;|`[^`]*?`)/g;
const num = /\b(\d+(?:\.\d+)?)\b/g;
const com = /(\/\/[^\n]*|#[^\n]*)/g;
const fn = /\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/g;
return lines
.replace(com, '<span class="cm-comment">$1</span>')
.replace(str, '<span class="cm-string">$1</span>')
.replace(kw, '<span class="cm-keyword">$1</span>')
.replace(num, '<span class="cm-number">$1</span>')
.replace(fn, '<span class="cm-fn">$1</span>');
};
// ---- persistence ----
const STORAGE_KEY = 'brainwave.exam.state';
const HISTORY_KEY = 'brainwave.exam.history';
const loadState = () => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch { return null; }
};
const saveState = (state) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
};
const clearState = () => { try { localStorage.removeItem(STORAGE_KEY); } catch {} };
const loadHistory = () => {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
};
const appendHistory = (entry) => {
try {
const hist = loadHistory();
hist.unshift(entry);
localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(0, 30)));
} catch {}
};
const clearHistory = () => { try { localStorage.removeItem(HISTORY_KEY); } catch {} };
// ---- validate exam JSON ----
const validateExam = (data) => {
if (!data || typeof data !== 'object') return 'Not a JSON object';
if (!data.title) return 'Missing "title"';
if (!Array.isArray(data.questions) || data.questions.length === 0) return 'Missing "questions" array';
for (const q of data.questions) {
if (!q.id) return `Question missing id`;
if (!q.type) return `Question "${q.id}" missing type`;
if (!q.prompt) return `Question "${q.id}" missing prompt`;
}
return null;
};
// Export to window
Object.assign(window, {
normalizeString, normalizeLatex,
arraysEqualUnordered, arraysEqualOrdered,
gradeQuestion, formatTime, formatDate,
renderLatex, escapeHtml, Tex, highlightCode,
loadState, saveState, clearState,
loadHistory, appendHistory, clearHistory,
validateExam,
});