Public Access
feat: add TweaksPanel and App controller — platform complete
This commit is contained in:
1 parent
cbe4e6058c
commit
61496b4b2f
2 files changed
+161
-1
No files matched your search
+104
-1
@@ -1,3 +1,106 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types'
|
||||||
|
import { gradeQuestion } from './utils/grading'
|
||||||
|
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
|
||||||
|
import Landing from './components/Landing'
|
||||||
|
import ExamRunner from './components/ExamRunner'
|
||||||
|
import Results from './components/Results'
|
||||||
|
import TweaksPanel from './components/TweaksPanel'
|
||||||
|
|
||||||
|
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <div className="shell"><header className="topbar"><div className="brand"><span className="brand-mark" /><span>BrainWave</span></div></header></div>
|
const [screen, setScreen] = useState<AppScreen>('landing')
|
||||||
|
const [exam, setExam] = useState<Exam | null>(null)
|
||||||
|
const [examState, setExamState] = useState<ExamState | null>(null)
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
|
||||||
|
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
|
||||||
|
const [tweaksOpen, setTweaksOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset['theme'] = tweaks.theme
|
||||||
|
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' }
|
||||||
|
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
|
||||||
|
}, [tweaks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen === 'exam' && exam && examState) saveState({ exam, state: examState })
|
||||||
|
}, [exam, examState, screen])
|
||||||
|
|
||||||
|
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
||||||
|
setTweaks((t) => ({ ...t, [key]: value }))
|
||||||
|
|
||||||
|
const loadExam = (data: Exam) => {
|
||||||
|
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
||||||
|
setExam(data)
|
||||||
|
setExamState({ currentIdx: 0, answers: {}, flags: {}, revealed: {}, startedAt: Date.now(), feedbackMode: fm })
|
||||||
|
setScreen('exam')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
const saved = loadState()
|
||||||
|
if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(saved.state); setScreen('exam') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = useCallback(() => {
|
||||||
|
if (!exam || !examState) return
|
||||||
|
const completedAt = Date.now()
|
||||||
|
const elapsedMs = completedAt - examState.startedAt
|
||||||
|
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
|
||||||
|
const entry: HistoryEntry = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
title: exam.title,
|
||||||
|
completedAt,
|
||||||
|
elapsedMs,
|
||||||
|
gradedCount: graded.filter((r) => r.status !== 'ungraded').length,
|
||||||
|
correctCount: graded.filter((r) => r.correct).length,
|
||||||
|
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
|
||||||
|
scorePoints: graded.reduce((a, r) => a + r.points, 0),
|
||||||
|
passingScore: exam.passingScore ?? 60,
|
||||||
|
exam,
|
||||||
|
state: { ...examState, completedAt },
|
||||||
|
}
|
||||||
|
appendHistory(entry)
|
||||||
|
setHistory(loadHistory())
|
||||||
|
clearState()
|
||||||
|
setExamState((s) => s ? { ...s, completedAt } : s)
|
||||||
|
setScreen('results')
|
||||||
|
}, [exam, examState])
|
||||||
|
|
||||||
|
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) }
|
||||||
|
const restart = () => { if (exam) loadExam(exam) }
|
||||||
|
const reviewAttempt = (entry: HistoryEntry) => { setExam(entry.exam); setExamState(entry.state); setScreen('review') }
|
||||||
|
const clearAllHistory = () => { if (confirm('Clear all past attempts?')) { clearHistory(); setHistory([]) } }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark" />
|
||||||
|
<span>BrainWave</span>
|
||||||
|
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-meta">
|
||||||
|
{screen === 'exam' && exam && (
|
||||||
|
<>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{screen === 'landing' && (
|
||||||
|
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
||||||
|
)}
|
||||||
|
{screen === 'exam' && exam && examState && (
|
||||||
|
<ExamRunner exam={exam} state={examState} setState={setExamState} onSubmit={submit} onExit={exit} />
|
||||||
|
)}
|
||||||
|
{(screen === 'results' || screen === 'review') && exam && examState && (
|
||||||
|
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
|
||||||
|
)}
|
||||||
|
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tweaks: Tweaks
|
||||||
|
update: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Seg({ tkey, options, tweaks, update }: {
|
||||||
|
tkey: keyof Tweaks
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
tweaks: Tweaks
|
||||||
|
update: Props['update']
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="tweak-control">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={`tweak-seg${tweaks[tkey] === opt.value ? ' active' : ''}`}
|
||||||
|
onClick={() => update(tkey, opt.value as never)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TweaksPanel({ tweaks, update, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="tweaks-panel">
|
||||||
|
<div className="tweaks-header">
|
||||||
|
<span>Tweaks</span>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="tweaks-body">
|
||||||
|
<div className="tweak-row">
|
||||||
|
<div className="tweak-label">Theme</div>
|
||||||
|
<Seg tkey="theme" options={[{ value: 'light' as ThemeMode, label: 'Light' }, { value: 'dark' as ThemeMode, label: 'Dark' }]} tweaks={tweaks} update={update} />
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<div className="tweak-label">Text size</div>
|
||||||
|
<Seg tkey="fontSize" options={[{ value: 'small' as FontSize, label: 'S' }, { value: 'medium' as FontSize, label: 'M' }, { value: 'large' as FontSize, label: 'L' }]} tweaks={tweaks} update={update} />
|
||||||
|
</div>
|
||||||
|
<div className="tweak-row">
|
||||||
|
<div className="tweak-label">Feedback</div>
|
||||||
|
<Seg tkey="feedbackMode" options={[
|
||||||
|
{ value: 'auto' as FeedbackTweak, label: 'Auto' },
|
||||||
|
{ value: 'immediate' as FeedbackTweak, label: 'Immediate' },
|
||||||
|
{ value: 'end' as FeedbackTweak, label: 'End' },
|
||||||
|
]} tweaks={tweaks} update={update} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user