Files
brainwave/docs/design_handoff_brainwave/app/screens.jsx
T
LaurensBartelds be69db04a5 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.
2026-04-21 22:19:06 +02:00

459 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 });