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

@@ -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 });