Public Access
feat: add ExamRunner screen
This commit is contained in:
1 parent
ef05f2c08b
commit
7217cd3a28
1 file changed
+211
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Exam, ExamState, Question } from '../types'
|
||||
import { gradeQuestion } from '../utils/grading'
|
||||
import { formatTime } from '../utils/formatting'
|
||||
import Tex from './Tex'
|
||||
import QuestionMedia from './QuestionMedia'
|
||||
import QuestionRenderer from './questions/QuestionRenderer'
|
||||
|
||||
interface Props {
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
setState: React.Dispatch<React.SetStateAction<ExamState>>
|
||||
onSubmit: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Props) {
|
||||
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: unknown) =>
|
||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }))
|
||||
|
||||
const goTo = useCallback((i: number) =>
|
||||
setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total])
|
||||
|
||||
const toggleFlag = useCallback(() =>
|
||||
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id])
|
||||
|
||||
const checkAnswer = () => {
|
||||
setState((s) => ({ ...s, revealed: { ...(s.revealed ?? {}), [q.id]: true } }))
|
||||
setRevealed(true)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === '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' && q.choices) {
|
||||
const ci = parseInt(e.key, 10) - 1
|
||||
if (q.choices[ci]) setAnswer(q.choices[ci].id)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [currentIdx, q, goTo, toggleFlag]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const graded = feedbackMode === 'immediate' && revealed
|
||||
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0)
|
||||
const result = graded ? gradeQuestion(q, answers[q.id]) : null
|
||||
|
||||
const navStatus = (qq: Question): string => {
|
||||
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
|
||||
const r = gradeQuestion(qq, answers[qq.id])
|
||||
if (r.status === 'correct') return 'correct'
|
||||
if (r.status === 'wrong') return 'wrong'
|
||||
}
|
||||
if (flags[qq.id]) return 'flagged'
|
||||
const a = answers[qq.id]
|
||||
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered'
|
||||
return ''
|
||||
}
|
||||
|
||||
const answeredCount = exam.questions.filter((qq) => {
|
||||
const st = navStatus(qq)
|
||||
return st === 'answered' || st === 'correct' || st === 'wrong'
|
||||
}).length
|
||||
|
||||
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0)
|
||||
|
||||
return (
|
||||
<div className="exam-shell">
|
||||
<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 ? ' ' + 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 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) === 1 ? 'pt' : 'pts'}</span>
|
||||
</header>
|
||||
|
||||
<Tex tag="div" className="q-prompt">{q.prompt}</Tex>
|
||||
<QuestionMedia q={q} />
|
||||
<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>{' '}
|
||||
<Tex>
|
||||
{Array.isArray(q.answer)
|
||||
? (q.answer as string[]).map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ')
|
||||
: (q.choices?.find((c) => c.id === q.answer)?.text ?? String(q.answer))}
|
||||
</Tex>
|
||||
</p>
|
||||
)}
|
||||
{q.explanation && <p><Tex>{q.explanation}</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' && q.choices && (
|
||||
<span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="meta-rail">
|
||||
<div className="meta-block">
|
||||
<div className="meta-label">Elapsed</div>
|
||||
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
|
||||
</div>
|
||||
{remainingMs != null && (
|
||||
<div className="meta-block">
|
||||
<div className="meta-label">Remaining</div>
|
||||
<div className={`meta-value mono${timeUrgent ? ' timer-urgent' : ''}`}>{formatTime(Math.max(0, remainingMs))}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="meta-block">
|
||||
<div className="meta-label">Points possible</div>
|
||||
<div className="meta-value mono">{totalPossible}</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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user