Public Access
- 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.
229 lines
7.0 KiB
React
229 lines
7.0 KiB
React
/* 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 />);
|