Public Access
489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import type { Exam, HistoryEntry } from '../types'
|
|
import { validateExam, parseExam } from '../utils/validation'
|
|
import { loadState } from '../utils/storage'
|
|
import { formatDate, formatTime } from '../utils/formatting'
|
|
|
|
interface Props {
|
|
onLoadExam: (exam: Exam) => void
|
|
history: HistoryEntry[]
|
|
onResume: () => void
|
|
onClearHistory: () => void
|
|
onReviewAttempt: (entry: HistoryEntry) => void
|
|
}
|
|
|
|
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: '◇' },
|
|
]
|
|
|
|
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
|
const [err, setErr] = useState<string | null>(null)
|
|
const [dragOver, setDragOver] = useState(false)
|
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleFile = async (file: File) => {
|
|
setErr(null)
|
|
try {
|
|
const text = await file.text()
|
|
const data = JSON.parse(text) as unknown
|
|
const msg = validateExam(data)
|
|
if (msg) throw new Error(msg)
|
|
onLoadExam(parseExam(data))
|
|
} catch (e) {
|
|
setErr(`Could not load: ${(e as Error).message}`)
|
|
}
|
|
}
|
|
|
|
const loadSample = async (path: string) => {
|
|
setErr(null)
|
|
try {
|
|
const r = await fetch(path)
|
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
|
|
const data = await r.json() as unknown
|
|
const msg = validateExam(data)
|
|
if (msg) throw new Error(msg)
|
|
onLoadExam(parseExam(data))
|
|
} catch (e) {
|
|
setErr(`Could not load sample: ${(e as Error).message}`)
|
|
}
|
|
}
|
|
|
|
const downloadInstructions = () => {
|
|
const content = `BRAINWAVE EXAM JSON — FORMAT GUIDE
|
|
====================================
|
|
Give this document to your preferred LLM (ChatGPT, Claude, Gemini, etc.) along with your exam content,
|
|
and ask it to produce a valid JSON file you can upload to BrainWave.
|
|
|
|
────────────────────────────────────
|
|
TOP-LEVEL STRUCTURE
|
|
────────────────────────────────────
|
|
{
|
|
"title": "string (REQUIRED) — The name of the exam",
|
|
"subtitle": "string (optional) — A short description shown under the title",
|
|
"author": "string (optional) — Who created the exam",
|
|
"timeLimitMinutes": number | null (optional) — Time limit in minutes, or null/omit for untimed,
|
|
"feedbackMode": "end" | "immediate" (optional, default: "end")
|
|
- "end": students see all feedback after submitting the full exam
|
|
- "immediate": feedback is shown after each question is answered,
|
|
"passingScore": number (optional) — Passing percentage, e.g. 70 means 70%,
|
|
"categories": ["string", ...] (optional) — Category labels for organizing questions,
|
|
"questions": [ ... ] (REQUIRED) — Array of question objects (see below)
|
|
}
|
|
|
|
────────────────────────────────────
|
|
QUESTION OBJECT (common fields)
|
|
────────────────────────────────────
|
|
Every question MUST have these fields:
|
|
"id": "string" — Unique identifier, e.g. "q1", "q2", etc.
|
|
"type": "string" — One of the supported types listed below
|
|
"prompt": "string" — The question text (supports LaTeX with $...$ or $$...$$)
|
|
|
|
Optional fields available on ALL question types:
|
|
"category": "string" — Which category this question belongs to
|
|
"points": number — Point value (default: 1)
|
|
"explanation": "string" — Explanation shown after grading (supports LaTeX)
|
|
"image": { "src": "URL", "alt": "string", "caption": "string" } — Attach an image
|
|
"audio": { "src": "URL" } — Attach an audio clip
|
|
"code": { "language": "string", "content": "string" } — Show a code block
|
|
"table": { "headers": ["Col1", "Col2"], "rows": [["a","b"], ["c","d"]] } — Show a data table
|
|
"svg": "string" — Raw SVG markup to display
|
|
|
|
────────────────────────────────────
|
|
QUESTION TYPES
|
|
────────────────────────────────────
|
|
|
|
1. MULTIPLE CHOICE ("multiple-choice")
|
|
Student picks ONE correct answer from a list.
|
|
|
|
Required fields:
|
|
"choices": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }, ...]
|
|
"answer": "a" ← the id of the correct choice
|
|
|
|
Example:
|
|
{
|
|
"id": "q1",
|
|
"type": "multiple-choice",
|
|
"prompt": "What is the capital of France?",
|
|
"choices": [
|
|
{ "id": "a", "text": "London" },
|
|
{ "id": "b", "text": "Paris" },
|
|
{ "id": "c", "text": "Berlin" }
|
|
],
|
|
"answer": "b"
|
|
}
|
|
|
|
2. MULTIPLE SELECT ("multiple-select")
|
|
Student selects ALL correct answers (checkboxes).
|
|
|
|
Required fields:
|
|
"choices": [{ "id": "a", "text": "Option A" }, ...]
|
|
"answer": ["a", "c"] ← array of correct choice ids
|
|
|
|
Example:
|
|
{
|
|
"id": "q2",
|
|
"type": "multiple-select",
|
|
"prompt": "Which are prime numbers?",
|
|
"choices": [
|
|
{ "id": "a", "text": "2" },
|
|
{ "id": "b", "text": "4" },
|
|
{ "id": "c", "text": "7" },
|
|
{ "id": "d", "text": "9" }
|
|
],
|
|
"answer": ["a", "c"]
|
|
}
|
|
|
|
3. SHORT ANSWER ("short-answer")
|
|
Student types a short text answer.
|
|
|
|
Required fields:
|
|
"answer": "string" — The correct answer
|
|
|
|
Optional fields:
|
|
"acceptedForms": ["alt1", "alt2"] — Other accepted answers
|
|
"caseSensitive": true|false (default: false)
|
|
"ignorePunctuation": true|false (default: false)
|
|
"ignoreAccents": true|false (default: false)
|
|
|
|
Example:
|
|
{
|
|
"id": "q3",
|
|
"type": "short-answer",
|
|
"prompt": "What element has the chemical symbol 'O'?",
|
|
"answer": "Oxygen",
|
|
"caseSensitive": false
|
|
}
|
|
|
|
4. TRANSLATION ("translation")
|
|
Student translates text between languages.
|
|
|
|
Required fields:
|
|
"answer": "string" — The correct translation
|
|
"sourceLanguage": "string" — e.g. "English"
|
|
"targetLanguage": "string" — e.g. "Spanish"
|
|
|
|
Optional fields:
|
|
"acceptedForms": ["alt translation 1", "alt translation 2"]
|
|
"caseSensitive": true|false
|
|
"ignorePunctuation": true|false
|
|
"ignoreAccents": true|false
|
|
|
|
Example:
|
|
{
|
|
"id": "q4",
|
|
"type": "translation",
|
|
"sourceLanguage": "English",
|
|
"targetLanguage": "Spanish",
|
|
"prompt": "Translate: \\"Good morning, how are you?\\"",
|
|
"answer": "Buenos días, ¿cómo estás?",
|
|
"acceptedForms": ["Buenos dias, como estas"],
|
|
"caseSensitive": false,
|
|
"ignorePunctuation": true,
|
|
"ignoreAccents": true
|
|
}
|
|
|
|
5. MATH INPUT ("math-input")
|
|
Student enters a LaTeX math expression.
|
|
|
|
Required fields:
|
|
"answer": "string" — Correct LaTeX expression
|
|
|
|
Optional fields:
|
|
"acceptedForms": ["alt LaTeX 1", "alt LaTeX 2"]
|
|
|
|
Example:
|
|
{
|
|
"id": "q5",
|
|
"type": "math-input",
|
|
"prompt": "Compute the derivative of $x^3$.",
|
|
"answer": "3x^2",
|
|
"acceptedForms": ["3x^{2}", "3 x^2"]
|
|
}
|
|
|
|
6. ESSAY ("essay")
|
|
Student writes a long-form response. Graded manually (always marked "ungraded").
|
|
|
|
Optional fields:
|
|
"minWords": number — Minimum word count required
|
|
"rubric": ["criterion 1", "criterion 2"] — Grading rubric shown during review
|
|
|
|
Example:
|
|
{
|
|
"id": "q6",
|
|
"type": "essay",
|
|
"prompt": "Discuss the causes of World War I.",
|
|
"points": 10,
|
|
"minWords": 100,
|
|
"rubric": [
|
|
"Mentions alliance systems",
|
|
"Discusses assassination of Archduke Franz Ferdinand",
|
|
"Addresses nationalism and imperialism",
|
|
"Well-structured argument"
|
|
]
|
|
}
|
|
|
|
7. MATCH ("match")
|
|
Student matches items from a left column to items in a right column.
|
|
|
|
Required fields:
|
|
"left": [{ "id": "l1", "text": "Item A" }, { "id": "l2", "text": "Item B" }, ...]
|
|
"right": [{ "id": "r1", "text": "Match 1" }, { "id": "r2", "text": "Match 2" }, ...]
|
|
"answer": { "l1": "r2", "l2": "r1" } ← maps each left id to its correct right id
|
|
|
|
Example:
|
|
{
|
|
"id": "q7",
|
|
"type": "match",
|
|
"prompt": "Match the country to its capital.",
|
|
"left": [
|
|
{ "id": "l1", "text": "France" },
|
|
{ "id": "l2", "text": "Japan" },
|
|
{ "id": "l3", "text": "Brazil" }
|
|
],
|
|
"right": [
|
|
{ "id": "r1", "text": "Brasília" },
|
|
{ "id": "r2", "text": "Tokyo" },
|
|
{ "id": "r3", "text": "Paris" }
|
|
],
|
|
"answer": { "l1": "r3", "l2": "r2", "l3": "r1" }
|
|
}
|
|
|
|
8. ORDER ("order")
|
|
Student arranges items in the correct sequence (drag & drop).
|
|
|
|
Required fields:
|
|
"items": [{ "id": "s1", "text": "Step 1" }, { "id": "s2", "text": "Step 2" }, ...]
|
|
"answer": ["s1", "s2", ...] ← array of item ids in the correct order
|
|
|
|
Example:
|
|
{
|
|
"id": "q8",
|
|
"type": "order",
|
|
"prompt": "Put these historical events in chronological order.",
|
|
"items": [
|
|
{ "id": "e1", "text": "Moon landing (1969)" },
|
|
{ "id": "e2", "text": "Fall of the Berlin Wall (1989)" },
|
|
{ "id": "e3", "text": "World Wide Web invented (1991)" },
|
|
{ "id": "e4", "text": "First iPhone released (2007)" }
|
|
],
|
|
"answer": ["e1", "e2", "e3", "e4"]
|
|
}
|
|
|
|
────────────────────────────────────
|
|
LATEX SUPPORT
|
|
────────────────────────────────────
|
|
Use LaTeX math in prompts, choices, and explanations:
|
|
- Inline math: $x^2 + y^2 = z^2$
|
|
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
|
|
|
|
Remember to double-escape backslashes in JSON strings:
|
|
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
|
|
|
|
────────────────────────────────────
|
|
MEDIA ATTACHMENTS
|
|
────────────────────────────────────
|
|
You can attach images, audio, code blocks, tables, and SVGs to any question.
|
|
Use publicly accessible URLs for image and audio sources.
|
|
|
|
Image: "image": { "src": "https://example.com/photo.jpg", "alt": "Description", "caption": "Optional caption" }
|
|
Audio: "audio": { "src": "https://example.com/clip.mp3" }
|
|
Code: "code": { "language": "python", "content": "def hello():\\n print('hi')" }
|
|
Table: "table": { "headers": ["Name", "Value"], "rows": [["A", "1"], ["B", "2"]] }
|
|
|
|
────────────────────────────────────
|
|
COMPLETE MINI EXAMPLE
|
|
────────────────────────────────────
|
|
{
|
|
"title": "Biology 101 — Cell Structure Quiz",
|
|
"subtitle": "Chapters 3-4 review",
|
|
"author": "Prof. Smith",
|
|
"timeLimitMinutes": 15,
|
|
"feedbackMode": "end",
|
|
"passingScore": 70,
|
|
"categories": ["Cells", "Organelles"],
|
|
"questions": [
|
|
{
|
|
"id": "q1",
|
|
"type": "multiple-choice",
|
|
"category": "Cells",
|
|
"points": 2,
|
|
"prompt": "Which organelle is known as the 'powerhouse of the cell'?",
|
|
"choices": [
|
|
{ "id": "a", "text": "Nucleus" },
|
|
{ "id": "b", "text": "Mitochondria" },
|
|
{ "id": "c", "text": "Ribosome" },
|
|
{ "id": "d", "text": "Golgi apparatus" }
|
|
],
|
|
"answer": "b",
|
|
"explanation": "Mitochondria produce ATP through cellular respiration."
|
|
},
|
|
{
|
|
"id": "q2",
|
|
"type": "short-answer",
|
|
"category": "Organelles",
|
|
"points": 1,
|
|
"prompt": "What is the gel-like fluid inside a cell called?",
|
|
"answer": "cytoplasm",
|
|
"acceptedForms": ["Cytoplasm", "cytosol"],
|
|
"caseSensitive": false
|
|
},
|
|
{
|
|
"id": "q3",
|
|
"type": "order",
|
|
"category": "Cells",
|
|
"points": 3,
|
|
"prompt": "Order these from smallest to largest:",
|
|
"items": [
|
|
{ "id": "a", "text": "Atom" },
|
|
{ "id": "b", "text": "Molecule" },
|
|
{ "id": "c", "text": "Organelle" },
|
|
{ "id": "d", "text": "Cell" }
|
|
],
|
|
"answer": ["a", "b", "c", "d"]
|
|
}
|
|
]
|
|
}
|
|
|
|
────────────────────────────────────
|
|
TIPS FOR LLM PROMPTING
|
|
────────────────────────────────────
|
|
When asking your LLM to create an exam, include:
|
|
1. This document (paste it in full)
|
|
2. The subject matter or source material
|
|
3. How many questions you want
|
|
4. Which question types to use
|
|
5. Desired difficulty level
|
|
6. Whether to include a time limit
|
|
|
|
Example prompt: "Using the attached format guide, create a 10-question exam on
|
|
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
|
|
Set a 20-minute time limit. Include explanations for each answer."
|
|
`
|
|
|
|
const blob = new Blob([content], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = 'brainwave-exam-format-guide.txt'
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const resumable = 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>
|
|
<button
|
|
type="button"
|
|
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) void 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) => { const f = e.target.files?.[0]; if (f) void handleFile(f) }}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost download-instructions-btn"
|
|
onClick={downloadInstructions}
|
|
id="download-instructions"
|
|
>
|
|
<span className="download-instructions-icon">↓</span>
|
|
Download Instructions — How to create your own exam JSON
|
|
</button>
|
|
|
|
{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.state.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) => (
|
|
<button key={s.path} type="button" className="sample" onClick={() => void 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>
|
|
</button>
|
|
))}
|
|
</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 }}>
|
|
{formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
|
|
</div>
|
|
</div>
|
|
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
|
|
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>Review</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|