Public Access
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:
1 parent
8420e6cae0
commit
be69db04a5
21 files changed
+6872
No files matched your search
@@ -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 });
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 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 = /("[^&]*?"|'[^&]*?'|`[^`]*?`)/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,
|
||||
});
|
||||
Reference in new issue
Block a user