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.
459 lines
20 KiB
React
459 lines
20 KiB
React
/* 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 });
|