Public Access
feat: add Landing screen
This commit is contained in:
1 parent
49cf77633c
commit
24d47e1830
1 file changed
+153
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user