feat: add Landing screen

This commit is contained in:
LaurensBartelds committed 2026-04-21 21:48:06 +02:00
1 parent 49cf77633c
commit 24d47e1830
1 file changed
+153
+153
View File
@@ -0,0 +1,153 @@
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 }: 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)
const data = await r.json() as unknown
onLoadExam(parseExam(data))
} catch (e) {
setErr(`Could not load sample: ${(e as Error).message}`)
}
}
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>
<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) 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) }}
/>
</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.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) => (
<div key={s.path} 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>
</div>
))}
</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>
)
}