Reported from a phone, all of it: - The menu button opened a drawer and then did nothing. Pressing the same button is how a thumb closes a drawer; the only way out was the strip of page beside it. It toggles now — chats, contents, a session's questions and a finished attempt's rail, all four. - AI Mode's chat list started at the top of the window, so its first row sat behind the header: unreadable, untappable, and covering the button that would have closed it. It starts below the header now, the way an article's contents already did, and the measurement they share is one hook rather than two. - The star that saves an article hung its panel from its right edge. That star is the first thing in the reading bar, so on a phone two hundred pixels of the panel were off the left of the screen, over the title. It measures and picks a side. - Cited questions were listed under "Sources". A question is not something you read, it is something you sit — so it stays out of the list and out of the count, and still counts towards the session the button builds. - The session offer counted its questions out loud, which invites haggling over a number the learner does not set. "Practise this", then "Your session is ready". Twenty is the cap, as it was. - Asked for five questions, the model explained itself: how many it had looked at, what it could go and fetch. It is now told to ignore the number, not to apologise for it, not to offer to find more, and to say the same thing again if asked again. Also: AI refine is off the reading page. Drafting is drafting — it belongs in the editor, next to Save, not on the page a learner is reading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2233 lines
105 KiB
JavaScript
2233 lines
105 KiB
JavaScript
import { optionLetter } from '../utils/options'
|
||
import { uploadUrl } from '../utils/uploads'
|
||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||
import Difficulty from '../components/Difficulty'
|
||
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
|
||
import lazyPage from '../utils/lazyPage'
|
||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||
import RichText from '../components/RichText'
|
||
import { mergeTextRanges } from '../utils/highlightOffsets'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import api from '../api/client'
|
||
import useMediaQuery from '../hooks/useMediaQuery'
|
||
import { useClaimSessionChrome } from '../context/SessionChrome'
|
||
import useAwayDetector from '../hooks/useAwayDetector'
|
||
import { useSessionDrawer } from '../context/SessionDrawer'
|
||
import FigureStrip from '../components/FigureStrip'
|
||
import FeedbackForm from '../components/FeedbackForm'
|
||
import ShareSession from '../components/ShareSession'
|
||
import MoreMenu from '../components/MoreMenu'
|
||
import '../components/Feedback.css'
|
||
import QuizTools, { QuizDialog, LabValues } from '../components/QuizTools'
|
||
import './QuizPlayer.css'
|
||
|
||
//: Seconds left when the block says so. Long enough to finish the question in
|
||
//: front of you and go back for one more; short enough to mean something.
|
||
const FIVE_MINUTES = 300
|
||
|
||
|
||
const TeachChat = lazyPage(() => import('../components/TeachChat'))
|
||
|
||
const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
||
const QUESTION_HIGHLIGHT_WORDS = 8
|
||
const OPTION_HIGHLIGHT_WORDS = 7
|
||
const TTS_PRELOAD_AHEAD = 5
|
||
|
||
|
||
function removeTextRange(ranges, removeRange) {
|
||
const next = []
|
||
ranges.forEach(range => {
|
||
if (removeRange.end <= range.start || removeRange.start >= range.end) {
|
||
next.push(range)
|
||
return
|
||
}
|
||
if (removeRange.start > range.start) next.push({ start: range.start, end: removeRange.start })
|
||
if (removeRange.end < range.end) next.push({ start: removeRange.end, end: range.end })
|
||
})
|
||
return mergeTextRanges(next)
|
||
}
|
||
|
||
function getSpeechChunkRange(text, maxWords, activeChunk) {
|
||
if (activeChunk === null || activeChunk === undefined) return null
|
||
const words = []
|
||
const re = /\S+/g
|
||
let match
|
||
while ((match = re.exec(text || ''))) words.push({ start: match.index, end: match.index + match[0].length })
|
||
const startWord = activeChunk * maxWords
|
||
if (!words[startWord]) return null
|
||
const endWord = Math.min(startWord + maxWords - 1, words.length - 1)
|
||
return { start: words[startWord].start, end: words[endWord].end }
|
||
}
|
||
|
||
function getManualHighlightSelection(selection = window.getSelection?.()) {
|
||
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
|
||
|
||
const offsetFromNode = (node, offset) => {
|
||
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
||
const span = element?.closest?.('[data-manual-highlight-id]')
|
||
if (!span) return null
|
||
let charOffset = offset
|
||
if (node.nodeType !== Node.TEXT_NODE) {
|
||
charOffset = offset <= 0 ? 0 : Number(span.dataset.end || span.dataset.start || 0) - Number(span.dataset.start || 0)
|
||
}
|
||
return {
|
||
id: span.dataset.manualHighlightId,
|
||
offset: Number(span.dataset.start || 0) + charOffset,
|
||
}
|
||
}
|
||
|
||
const range = selection.getRangeAt(0)
|
||
const start = offsetFromNode(range.startContainer, range.startOffset)
|
||
const end = offsetFromNode(range.endContainer, range.endOffset)
|
||
if (!start || !end || start.id !== end.id) return null
|
||
const ordered = start.offset <= end.offset ? { start: start.offset, end: end.offset } : { start: end.offset, end: start.offset }
|
||
if (ordered.end <= ordered.start) return null
|
||
return { id: start.id, ...ordered }
|
||
}
|
||
|
||
function splitSpeechChunks(text, maxWords) {
|
||
const words = (text || '').trim().split(/\s+/).filter(Boolean)
|
||
if (!words.length) return []
|
||
const chunks = []
|
||
for (let i = 0; i < words.length; i += maxWords) {
|
||
chunks.push(words.slice(i, i + maxWords).join(' '))
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
function questionStem(question) {
|
||
return (question?.question_text || '').replace('[IMAGE]', '').trim()
|
||
}
|
||
|
||
function getQuestionSpeechSegments(question, index) {
|
||
if (!question) return []
|
||
const segments = []
|
||
splitSpeechChunks(questionStem(question), QUESTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
|
||
segments.push({
|
||
type: 'question',
|
||
chunkIndex,
|
||
text: chunkIndex === 0 ? `Question ${index + 1}. ${chunk}` : chunk,
|
||
})
|
||
})
|
||
if (question.options?.length) segments.push({ type: 'meta', text: 'Options.' })
|
||
;(question.options || []).forEach((option, i) => {
|
||
splitSpeechChunks(option, OPTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
|
||
segments.push({
|
||
type: 'option',
|
||
index: i,
|
||
chunkIndex,
|
||
text: chunkIndex === 0 ? `${OPTION_LETTERS[i] || i + 1}. ${chunk}` : chunk,
|
||
})
|
||
})
|
||
})
|
||
return segments
|
||
}
|
||
|
||
function buildQuestionSpeechText(question, index) {
|
||
const segments = getQuestionSpeechSegments(question, index)
|
||
return segments.map(s => s.text).join(' ')
|
||
}
|
||
|
||
function getActiveSpeechSegment(audio, segments) {
|
||
if (!segments.length) return null
|
||
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
|
||
const weights = segments.map(segment => Math.max(12, segment.text.length))
|
||
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
||
const target = (audio.currentTime / audio.duration) * total
|
||
let cursor = 0
|
||
for (let i = 0; i < weights.length; i += 1) {
|
||
cursor += weights[i]
|
||
if (target <= cursor) return i
|
||
}
|
||
return segments.length - 1
|
||
}
|
||
|
||
function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange, getAudio, autoPlay, onEnded }) {
|
||
const [state, setState] = useState('idle') // idle | loading | playing
|
||
const audioRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
audioRef.current?.pause()
|
||
onActiveChange?.(false)
|
||
onSegmentChange?.(null)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (autoPlay && state === 'idle') speak()
|
||
}, [autoPlay])
|
||
|
||
const setStateAndNotify = (s) => {
|
||
setState(s)
|
||
onActiveChange?.(s !== 'idle')
|
||
if (s === 'idle') onSegmentChange?.(null)
|
||
}
|
||
|
||
const speak = async () => {
|
||
if (state === 'playing') {
|
||
audioRef.current?.pause()
|
||
setStateAndNotify('idle')
|
||
return
|
||
}
|
||
if (state === 'loading') return
|
||
try {
|
||
setStateAndNotify('loading')
|
||
let url
|
||
let revokeOnDone = false
|
||
if (getAudio) {
|
||
url = (await getAudio(text, voice))?.url
|
||
} else {
|
||
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||
url = URL.createObjectURL(res.data)
|
||
revokeOnDone = true
|
||
}
|
||
if (!url) throw new Error('No audio returned')
|
||
const audio = new Audio(url)
|
||
audioRef.current = audio
|
||
const updateSegment = () => {
|
||
const activeIndex = getActiveSpeechSegment(audio, segments)
|
||
onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null)
|
||
}
|
||
audio.onloadedmetadata = updateSegment
|
||
audio.ontimeupdate = updateSegment
|
||
audio.onended = () => { setStateAndNotify('idle'); onEnded?.(); if (revokeOnDone) URL.revokeObjectURL(url) }
|
||
audio.onerror = () => { setStateAndNotify('idle'); if (revokeOnDone) URL.revokeObjectURL(url) }
|
||
await audio.play()
|
||
setStateAndNotify('playing')
|
||
updateSegment()
|
||
} catch { setStateAndNotify('idle') }
|
||
}
|
||
|
||
const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : '🔊 Listen'
|
||
const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff'
|
||
const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3'
|
||
|
||
return (
|
||
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
|
||
background: bg, color, border: 'none', borderRadius: '6px',
|
||
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
|
||
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
|
||
}}>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const clock = (seconds) => {
|
||
const s = Math.max(0, Math.round(seconds))
|
||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||
}
|
||
|
||
// Hours as well as minutes, for the one figure a candidate looks at most. A
|
||
// block clock reading 89:00 is a different amount of time depending on how
|
||
// long you thought the block was, and it is read at a glance.
|
||
const blockClock = (seconds) => {
|
||
const s = Math.max(0, Math.round(seconds))
|
||
return [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60]
|
||
.map(part => String(part).padStart(2, '0')).join(':')
|
||
}
|
||
|
||
/**
|
||
* Session time, time on this question, and the running average.
|
||
*
|
||
* Shown in study mode as well as exam mode: knowing you have spent four minutes
|
||
* on one question is exactly as useful when nothing is counting down, and it is
|
||
* the number that tells you whether you are learning or stuck. It can be paused,
|
||
* because time spent making tea is not time spent thinking.
|
||
*/
|
||
function SessionClock({ sessionSeconds, questionSeconds, answered, paused, onTogglePause }) {
|
||
const average = answered > 0 ? sessionSeconds / answered : null
|
||
return (
|
||
<div className="quiz-clock">
|
||
<button type="button" className="quiz-clock-pause" aria-pressed={paused}
|
||
onClick={onTogglePause} title={paused ? 'Resume the clock' : 'Pause the clock'}>
|
||
{paused ? '▶' : '⏸'}
|
||
</button>
|
||
<span className="quiz-clock-cell">
|
||
<strong>{Math.floor(sessionSeconds / 3600)}h {String(Math.floor((sessionSeconds % 3600) / 60)).padStart(2, '0')}m</strong>
|
||
<em>session</em>
|
||
</span>
|
||
<span className="quiz-clock-cell">
|
||
<strong>{clock(questionSeconds)}</strong>
|
||
<em>question</em>
|
||
</span>
|
||
{average != null && (
|
||
<span className="quiz-clock-cell">
|
||
<strong>{clock(average)}</strong>
|
||
<em>average</em>
|
||
</span>
|
||
)}
|
||
{paused && <span className="quiz-clock-paused">paused</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TimerDisplay({ seconds, total }) {
|
||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||
const mins = Math.floor(seconds / 60), secs = seconds % 60
|
||
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
|
||
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
|
||
</div>
|
||
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** The player's ⋯ menu: the shared one, keeping its own look. */
|
||
function MoreActions({ children }) {
|
||
return (
|
||
<MoreMenu className="quiz-more" menuClassName="quiz-more-menu">{children}</MoreMenu>
|
||
)
|
||
}
|
||
|
||
|
||
// Mode selection prompt removed: general quizzes start in their own mode automatically.
|
||
function getQuizSessionId() {
|
||
const key = 'pedshub_quiz_session_id'
|
||
try {
|
||
const existing = localStorage.getItem(key)
|
||
if (existing) return existing
|
||
const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||
localStorage.setItem(key, created)
|
||
return created
|
||
} catch {
|
||
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
||
}
|
||
}
|
||
|
||
// Stable per-device session ID lets the same app/webview resume after restart.
|
||
const SESSION_ID = getQuizSessionId()
|
||
|
||
export default function QuizPage() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
// True when we are showing the session rather than sitting it.
|
||
const [showOverview, setShowOverview] = useState(false)
|
||
const [searchParams] = useSearchParams()
|
||
const restartRequested = searchParams.get('restart') === '1'
|
||
const { user } = useAuth()
|
||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||
const [quiz, setQuiz] = useState(null)
|
||
const [voices, setVoices] = useState([])
|
||
const [selectedVoice, setSelectedVoice] = useState('')
|
||
const [ttsActive, setTtsActive] = useState(false)
|
||
const [readThrough, setReadThrough] = useState(false)
|
||
const [quizMode, setQuizMode] = useState(null)
|
||
const [answers, setAnswers] = useState({})
|
||
const [currentIdx, setCurrentIdx] = useState(0)
|
||
const [loading, setLoading] = useState(true)
|
||
const [starting, setStarting] = useState(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [attemptId, setAttemptId] = useState(null)
|
||
const [timeLeft, setTimeLeft] = useState(null)
|
||
const [totalTime, setTotalTime] = useState(null)
|
||
const [toast, setToast] = useState('')
|
||
const [navOpen, setNavOpen] = useState(false)
|
||
const navOpenRef = useRef(false)
|
||
useEffect(() => { navOpenRef.current = navOpen })
|
||
const [drawerTab, setDrawerTab] = useState('questions')
|
||
// The session rail is the navigator whenever there is room for it; the
|
||
// dropdown only exists for screens too narrow to show it. Matches the
|
||
// 1150px breakpoint in QuizPlayer.css that hides the rail.
|
||
const sessionDrawer = useSessionDrawer()
|
||
const hasRail = useMediaQuery('(min-width: 1151px)')
|
||
// Only once a session is actually being sat. The screen that asks
|
||
// whether to start one is an ordinary card and wants the ordinary page.
|
||
useClaimSessionChrome(!!quizMode)
|
||
|
||
// The site's burger opens this session's questions while there is no rail.
|
||
// Only while there is no rail: on a desktop the list is already beside the
|
||
// question, and taking the button over there would hide the site menu for
|
||
// no reason.
|
||
const { register: registerDrawer } = sessionDrawer
|
||
useEffect(() => {
|
||
if (hasRail) return undefined
|
||
// Toggling, so the button that opened the drawer also shuts it. Read
|
||
// through a ref rather than closed over, so the registration does not have
|
||
// to be torn down and remade every time the drawer moves.
|
||
return registerDrawer(() => {
|
||
if (navOpenRef.current) { setNavOpen(false); return }
|
||
setDrawerTab('questions')
|
||
setNavOpen(true)
|
||
})
|
||
}, [hasRail, registerDrawer])
|
||
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||
const [imageZoom, setImageZoom] = useState(1)
|
||
const [startedAt, setStartedAt] = useState(null)
|
||
// Wall-clock seconds spent in this session and on the question in front of
|
||
// you. Kept here rather than derived from startedAt so pausing can stop them.
|
||
const [sessionSeconds, setSessionSeconds] = useState(0)
|
||
const [questionSeconds, setQuestionSeconds] = useState(0)
|
||
const [clockPaused, setClockPaused] = useState(false)
|
||
// Asked before the session closes. Leaving is not destructive — the answers
|
||
// are saved and it can be resumed — but it is not what a mis-aimed thumb
|
||
// should do either.
|
||
const [leaving, setLeaving] = useState(false)
|
||
// Said once, when five minutes are left. A block that ends without warning
|
||
// ends on whatever question you happened to be reading.
|
||
const [fiveLeft, setFiveLeft] = useState(false)
|
||
const warnedRef = useRef(false)
|
||
// The clock ran out: the answers are in, and the analysis waits behind an
|
||
// acknowledgement rather than replacing the exam without a word.
|
||
const [timeUp, setTimeUp] = useState(false)
|
||
const timeUpRef = useRef(false)
|
||
// The attempt is closed: it was handed in, or the clock ran out and it was
|
||
// handed in for you. This is what "finished" means. It used to be read off
|
||
// the answer count, which called a block finished only once every question
|
||
// had an answer — an exam that ran out with nothing answered is just as
|
||
// over, and that was the one case the old reading got wrong.
|
||
const [attemptClosed, setAttemptClosed] = useState(false)
|
||
// Seconds spent on each question, banked when you leave it. Without this the
|
||
// analysis can report a total but never a per-question time.
|
||
const [questionTimes, setQuestionTimes] = useState({})
|
||
// Questions where a tip was opened before the answer went in. Right after a
|
||
// nudge is still right — it is counted as correct — but it is not the same
|
||
// as right, and the analysis says which.
|
||
const [hints, setHints] = useState([])
|
||
const [favorites, setFavorites] = useState([])
|
||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||
const [manualHighlights, setManualHighlights] = useState({})
|
||
const [tool, setTool] = useState(null)
|
||
// Labs sit beside the question rather than over it: a reference range is
|
||
// read while re-reading the case, and a dialog covers the thing it is for.
|
||
const [labsOpen, setLabsOpen] = useState(false)
|
||
// The rail is a sidebar, not furniture: on a long stem the question wants
|
||
// the width, and the list is still one press away. Remembered, because
|
||
// somebody who put it away meant it.
|
||
const [railOpen, setRailOpen] = useState(() => {
|
||
try { return localStorage.getItem('pedshub.quizRail') !== 'closed' } catch { return true }
|
||
})
|
||
|
||
useEffect(() => {
|
||
try { localStorage.setItem('pedshub.quizRail', railOpen ? 'open' : 'closed') }
|
||
catch { /* private browsing */ }
|
||
}, [railOpen])
|
||
// Which of the per-question panels is open. One at a time: they sit in the
|
||
// same place under the stem, and two at once would push the options off screen.
|
||
const [panel, setPanel] = useState(null)
|
||
const [note, setNote] = useState('')
|
||
const [noteSaved, setNoteSaved] = useState(true)
|
||
const [folders, setFolders] = useState([])
|
||
// Searching the folders you have and naming one you do not, in the same box.
|
||
const [folderQuery, setFolderQuery] = useState('')
|
||
const [folderBusy, setFolderBusy] = useState(false)
|
||
const [showReview, setShowReview] = useState(false)
|
||
const [responseStats, setResponseStats] = useState(null)
|
||
const [statsError, setStatsError] = useState('')
|
||
const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0')
|
||
const [showAllExplanations, setShowAllExplanations] = useState(false)
|
||
// Questions whose answer was asked for rather than given. Being shown the
|
||
// answer is not answering: these stay out of `answers`, so the rail, the
|
||
// count and what is handed in all still say the question is unanswered.
|
||
const [revealed, setRevealed] = useState(() => new Set())
|
||
// Which options have had their reasoning opened by clicking them. Separate
|
||
// from the show-all toggle so one does not fight the other.
|
||
const [openExplanations, setOpenExplanations] = useState(() => new Set())
|
||
const toggleOptionExplanation = (index) => setOpenExplanations(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(index)) next.delete(index)
|
||
else next.add(index)
|
||
return next
|
||
})
|
||
const toggleStats = () => setShowStats(value => {
|
||
localStorage.setItem('pedshub_show_stats', value ? '0' : '1')
|
||
return !value
|
||
})
|
||
const [submitError, setSubmitError] = useState('')
|
||
const [resumeError, setResumeError] = useState('')
|
||
const [resumeRetry, setResumeRetry] = useState(0)
|
||
const [progressError, setProgressError] = useState('')
|
||
const timerRef = useRef(null)
|
||
const toastRef = useRef(null)
|
||
const hasStarted = useRef(false)
|
||
// Indexes the learner has opened, so the rail reveals text gradually.
|
||
const [seenIndexes, setSeenIndexes] = useState(() => new Set([0]))
|
||
const ttsCacheRef = useRef(new Map())
|
||
const autoAdvanceRef = useRef(null)
|
||
const savedHighlightSelectionRef = useRef(null)
|
||
const autoHighlightTimerRef = useRef(null)
|
||
|
||
const showToast = (msg) => {
|
||
setToast(msg)
|
||
clearTimeout(toastRef.current)
|
||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||
}
|
||
|
||
const adjustImageZoom = (delta) => {
|
||
setImageZoom(z => Math.max(1, Math.min(4, z + delta)))
|
||
}
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem(`quiz-highlights:${id}`)
|
||
setManualHighlights(saved ? JSON.parse(saved) : {})
|
||
} catch {
|
||
setManualHighlights({})
|
||
}
|
||
}, [id])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(`quiz-highlights:${id}`, JSON.stringify(manualHighlights))
|
||
} catch { }
|
||
}, [id, manualHighlights])
|
||
|
||
|
||
// Suspending is not abandoning: it ends on the session's own analysis, where
|
||
// what has been answered so far is scored and the Resume button sits.
|
||
//: Withheld until the server says otherwise. Defaulting to allowed made the
|
||
//: tutor's button appear for a moment on every session and then vanish on a
|
||
//: site that has it switched off, which reads as a bug rather than a policy.
|
||
const [tutorAllowed, setTutorAllowed] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let live = true
|
||
api.get('/teach/policy')
|
||
.then(res => { if (live) setTutorAllowed(res.data?.in_quiz !== false) })
|
||
.catch(() => {})
|
||
return () => { live = false }
|
||
}, [])
|
||
|
||
const exitTarget = () => (attemptId ? `/sessions/${attemptId}` : '/')
|
||
|
||
const questions = quiz?.questions || []
|
||
// ?q=3 means "open on question 3". The analytics table links here that way:
|
||
// clicking a row in a session you have not finished should put you on that
|
||
// question, ready to answer it, rather than back at the start.
|
||
const wantedQuestion = Number(searchParams.get('q'))
|
||
const jumped = useRef(false)
|
||
useEffect(() => {
|
||
if (jumped.current || !questions.length) return
|
||
if (!Number.isInteger(wantedQuestion) || wantedQuestion < 1) return
|
||
jumped.current = true
|
||
setCurrentIdx(Math.min(wantedQuestion, questions.length) - 1)
|
||
}, [questions.length, wantedQuestion])
|
||
|
||
const current = questions[currentIdx]
|
||
const isStudy = quizMode === 'study'
|
||
|
||
// Away from the desk is not time spent on the question, whether or not the
|
||
// tab is still in front. Only while a session is actually being sat.
|
||
const { asking: askingStillHere, away, confirmHere } =
|
||
useAwayDetector({ enabled: !!attemptId && !!quizMode })
|
||
const clockStopped = clockPaused || askingStillHere || away
|
||
|
||
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
|
||
if (!selected || !current) return
|
||
const [questionKey, fieldKey] = selected.id.split('::')
|
||
if (questionKey !== String(current.id)) return
|
||
|
||
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||
const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end)
|
||
|
||
setManualHighlights(prev => {
|
||
const questionRanges = prev[current.id] || {}
|
||
const currentRanges = questionRanges[fieldKey] || []
|
||
const nextRanges = removeExisting
|
||
? removeTextRange(currentRanges, selected)
|
||
: mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }])
|
||
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||
const next = { ...prev, [current.id]: nextQuestion }
|
||
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||
return next
|
||
})
|
||
|
||
window.getSelection?.().removeAllRanges()
|
||
savedHighlightSelectionRef.current = null
|
||
}, [current?.id, manualHighlights])
|
||
|
||
const captureHighlightSelection = useCallback(() => {
|
||
const selected = getManualHighlightSelection()
|
||
if (!selected || !current) return
|
||
const [questionKey] = selected.id.split('::')
|
||
if (questionKey !== String(current.id)) return
|
||
savedHighlightSelectionRef.current = selected
|
||
clearTimeout(autoHighlightTimerRef.current)
|
||
autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450)
|
||
}, [current?.id, applyManualHighlightSelection])
|
||
|
||
useEffect(() => {
|
||
document.addEventListener('selectionchange', captureHighlightSelection)
|
||
document.addEventListener('mouseup', captureHighlightSelection)
|
||
document.addEventListener('touchend', captureHighlightSelection)
|
||
return () => {
|
||
clearTimeout(autoHighlightTimerRef.current)
|
||
document.removeEventListener('selectionchange', captureHighlightSelection)
|
||
document.removeEventListener('mouseup', captureHighlightSelection)
|
||
document.removeEventListener('touchend', captureHighlightSelection)
|
||
}
|
||
}, [captureHighlightSelection])
|
||
|
||
const fetchTtsAudio = useCallback(async (text, voice) => {
|
||
const cleanText = (text || '').trim()
|
||
if (!cleanText) return null
|
||
const key = `${voice || 'default'}::${cleanText}`
|
||
const cached = ttsCacheRef.current.get(key)
|
||
if (cached?.url) return cached
|
||
if (cached?.promise) return cached.promise
|
||
|
||
const promise = api.post('/tts/speak', { text: cleanText, voice: voice || null }, { responseType: 'blob' })
|
||
.then(res => {
|
||
const entry = { url: URL.createObjectURL(res.data) }
|
||
ttsCacheRef.current.set(key, entry)
|
||
return entry
|
||
})
|
||
.catch(err => {
|
||
if (ttsCacheRef.current.get(key)?.promise === promise) ttsCacheRef.current.delete(key)
|
||
throw err
|
||
})
|
||
ttsCacheRef.current.set(key, { promise })
|
||
return promise
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
clearTimeout(autoAdvanceRef.current)
|
||
ttsCacheRef.current.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url) })
|
||
ttsCacheRef.current.clear()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
setActiveReadSegment(null)
|
||
setTtsActive(false)
|
||
setTyped('')
|
||
setOpenExplanations(new Set())
|
||
savedHighlightSelectionRef.current = null
|
||
clearTimeout(autoHighlightTimerRef.current)
|
||
if (!readThrough) setActiveReadSegment(null)
|
||
clearTimeout(autoAdvanceRef.current)
|
||
}, [currentIdx, readThrough])
|
||
|
||
useEffect(() => {
|
||
if (!quizMode || !questions.length || !voices.length) return
|
||
for (let index = currentIdx; index <= Math.min(currentIdx + TTS_PRELOAD_AHEAD, questions.length - 1); index += 1) {
|
||
const q = questions[index]
|
||
if (!q) return
|
||
fetchTtsAudio(buildQuestionSpeechText(q, index), selectedVoice).catch(() => {})
|
||
}
|
||
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
|
||
|
||
const resumeQuiz = useCallback(async (saved, availableVoices = []) => {
|
||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||
const savedAnswers = saved.answers || {}
|
||
const aid = saved.attempt_id || saved.attemptId
|
||
if (!aid) return
|
||
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}`)
|
||
const mode = quizRes.data.attempt_mode === 'study' ? 'study' : 'exam'
|
||
setQuiz(quizRes.data)
|
||
|
||
hasStarted.current = true
|
||
setQuizMode(mode)
|
||
setAnswers(savedAnswers)
|
||
// A tip read before the break was still read; closing the tab does not
|
||
// unsee it.
|
||
setHints((saved.hints || []).map(Number).filter(Number.isFinite))
|
||
setCurrentIdx(savedIdx)
|
||
setAttemptId(saved.attempt_id || saved.attemptId)
|
||
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
||
if (saved.started_at) setStartedAt(saved.started_at)
|
||
// What the player last saved, not what a wall clock would have spent. The
|
||
// exam's clock runs only while the exam is on screen, so an afternoon away
|
||
// from the tab is not an afternoon of the block — computing it from
|
||
// `started_at` charged for exactly that, and disagreed with the server,
|
||
// which has read `time_left` first for some time now.
|
||
if (saved.total_time) {
|
||
const held = saved.time_left
|
||
const remaining = held != null
|
||
? Math.max(0, Number(held))
|
||
: Math.max(0, saved.total_time - Math.floor((new Date() - new Date(saved.started_at)) / 1000))
|
||
setTimeLeft(remaining)
|
||
setTotalTime(saved.total_time)
|
||
}
|
||
}, [id])
|
||
|
||
// Warn before tab/window close when mid-quiz
|
||
useEffect(() => {
|
||
if (!attemptId) return
|
||
const msg = progressError || (timeLeft !== null
|
||
? 'This exam is timed. Your answers are saved, and the clock stops while the exam is off screen — nothing is handed in until you do it.'
|
||
: 'You have an in-progress quiz. Progress is saved while connected.')
|
||
const handler = (e) => { e.preventDefault(); e.returnValue = msg }
|
||
window.addEventListener('beforeunload', handler)
|
||
return () => window.removeEventListener('beforeunload', handler)
|
||
}, [attemptId, timeLeft, progressError])
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
setLoading(true)
|
||
setResumeError('')
|
||
try {
|
||
const [quizRes, voicesRes, favoritesRes] = await Promise.all([
|
||
api.get(`/quizzes/${id}`),
|
||
api.get('/tts/voices').catch(() => ({ data: [] })),
|
||
api.get('/favorites').catch(() => ({ data: [] })),
|
||
])
|
||
setQuiz(quizRes.data)
|
||
setVoices(voicesRes.data)
|
||
setFavorites(favoritesRes.data)
|
||
const def = voicesRes.data.find(v => v.is_default)
|
||
if (def) setSelectedVoice(def.id)
|
||
|
||
// Check for saved progress and auto-resume; otherwise start straight away
|
||
// in the quiz's own mode — no second mode prompt.
|
||
try {
|
||
// ?restart=1 (Repeat from the session list) always begins a new attempt.
|
||
if (restartRequested) {
|
||
await startAttempt(quizRes.data.mode === 'timed' ? 'exam' : 'study', null, null, true)
|
||
return
|
||
}
|
||
const progressRes = await api.get('/attempts/progress', {
|
||
params: { quiz_id: id },
|
||
headers: { 'x-quiz-session': SESSION_ID },
|
||
})
|
||
if (progressRes.data) {
|
||
await resumeQuiz(progressRes.data, voicesRes.data)
|
||
return
|
||
}
|
||
// Nothing saved and nothing asked for: show the session rather than
|
||
// launching it. Opening a link should not commit you to a
|
||
// 243-question exam before you have seen what it is.
|
||
if (quizRes.data && !searchParams.get('start')) {
|
||
setShowOverview(true)
|
||
} else if (quizRes.data) {
|
||
await startAttempt(quizRes.data.mode === 'timed' ? 'exam' : 'study', null, null)
|
||
}
|
||
} catch {
|
||
setResumeError('Could not restore your saved attempt. Retry resume before starting; your saved answers have not been replaced.')
|
||
}
|
||
} catch { navigate('/') }
|
||
finally { setLoading(false) }
|
||
}
|
||
load()
|
||
return () => clearInterval(timerRef.current)
|
||
}, [id, resumeRetry, restartRequested])
|
||
|
||
const startAttempt = async (mode, voice, timerMinutes = null, fresh = false) => {
|
||
hasStarted.current = true
|
||
setSelectedVoice(voice)
|
||
setStarting(true)
|
||
// DON'T set quizMode yet — wait for data to load first
|
||
// (prevents mobile race condition where quiz renders without correct_answer)
|
||
|
||
try {
|
||
// Start attempt first (may select random question subset)
|
||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}${fresh ? '&fresh=true' : ''}`)
|
||
mode = attemptRes.data.mode || mode
|
||
setAttemptId(attemptRes.data.id)
|
||
const aid = attemptRes.data.id
|
||
// A reused attempt may have newer progress from another tab/device.
|
||
const saved = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID } })
|
||
if (saved.data) {
|
||
await resumeQuiz(saved.data, voices)
|
||
return
|
||
}
|
||
|
||
// Fetch quiz with attempt_id for question pool filtering
|
||
let quizData = quiz
|
||
const studyParam = mode === 'study' ? '&study=true' : ''
|
||
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
|
||
quizData = quizRes.data
|
||
setQuiz(quizData)
|
||
const mins = timerMinutes || quizData.time_limit_minutes
|
||
const now = new Date().toISOString()
|
||
setStartedAt(now)
|
||
if (mode === 'exam' && mins) {
|
||
const secs = mins * 60
|
||
setTimeLeft(secs); setTotalTime(secs)
|
||
}
|
||
// NOW set mode — quiz data is fully loaded, safe to render
|
||
setQuizMode(mode)
|
||
api.post('/attempts/progress', {
|
||
quiz_id: parseInt(id),
|
||
attempt_id: aid,
|
||
answers: {},
|
||
current_idx: 0,
|
||
mode,
|
||
voice: voice || null,
|
||
time_left: mode === 'exam' && mins ? mins * 60 : null,
|
||
started_at: now,
|
||
total_time: mode === 'exam' && mins ? mins * 60 : null,
|
||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
|
||
} catch (err) {
|
||
hasStarted.current = false
|
||
throw err
|
||
}
|
||
finally { setStarting(false) }
|
||
}
|
||
|
||
const startQuiz = async (mode, voice, timerMinutes = null) => {
|
||
if (hasStarted.current || resumeError || loading) return
|
||
return startAttempt(mode, voice, timerMinutes)
|
||
}
|
||
|
||
useEffect(() => {
|
||
setSeenIndexes(prev => (prev.has(currentIdx) ? prev : new Set(prev).add(currentIdx)))
|
||
}, [currentIdx])
|
||
|
||
const timerStarted = timeLeft !== null
|
||
/**
|
||
* The exam clock runs only while the exam is on screen.
|
||
*
|
||
* It used to tick on a wall clock, so an hour away from the tab spent an
|
||
* hour of the exam on questions you were never shown. Time you were not
|
||
* given the questions for is not time you used — and it is what makes the
|
||
* per-question figures mean anything at all.
|
||
*/
|
||
useEffect(() => {
|
||
if (!timerStarted) return undefined
|
||
const start = () => {
|
||
clearInterval(timerRef.current)
|
||
if (document.hidden || clockStopped) return
|
||
timerRef.current = setInterval(() => {
|
||
setTimeLeft(t => {
|
||
if (t <= 1) { clearInterval(timerRef.current); return 0 }
|
||
if (t - 1 <= FIVE_MINUTES && !warnedRef.current) {
|
||
warnedRef.current = true
|
||
setFiveLeft(true)
|
||
}
|
||
return t - 1
|
||
})
|
||
}, 1000)
|
||
}
|
||
start()
|
||
document.addEventListener('visibilitychange', start)
|
||
return () => {
|
||
clearInterval(timerRef.current)
|
||
document.removeEventListener('visibilitychange', start)
|
||
}
|
||
}, [timerStarted, clockStopped])
|
||
|
||
// A warning with time to act on it. Said once, at five minutes: an exam that
|
||
// ends without notice is a scramble, and one that nags is a distraction.
|
||
const warnedAt = useRef(null)
|
||
useEffect(() => {
|
||
if (timeLeft === null || warnedAt.current) return
|
||
if (timeLeft > 300 || timeLeft <= 0) return
|
||
warnedAt.current = true
|
||
showToast('Five minutes left in this block.')
|
||
}, [timeLeft])
|
||
|
||
// Nothing is handed in behind the learner's back. The clock reaching zero
|
||
// stops the block and says so; the paper goes in when they close that
|
||
// dialog, which is the same order a real block ends in. An exam left open
|
||
// and walked away from is still open when it is picked up again — it shows
|
||
// this the moment it is back on screen rather than having been marked in the
|
||
// night by something the learner never saw.
|
||
useEffect(() => {
|
||
if (timeLeft !== 0) return
|
||
timeUpRef.current = true
|
||
setTimeUp(true)
|
||
}, [timeLeft])
|
||
|
||
/**
|
||
* A closed block is served with its answers; an open one never is.
|
||
*
|
||
* The exam player is sent questions with no correct option and no
|
||
* explanation — that is the integrity rule, and it is enforced on the server
|
||
* rather than by hiding what the page already holds. So review cannot simply
|
||
* un-hide anything: once the attempt is completed the same request returns
|
||
* the marked version, and this asks for it. A study session was served the
|
||
* answers at the start, so there is nothing here for it to fetch.
|
||
*/
|
||
useEffect(() => {
|
||
if (!attemptClosed || !attemptId) return undefined
|
||
if (questions.some(question => question.correct_answer)) return undefined
|
||
let live = true
|
||
api.get(`/quizzes/${id}?attempt_id=${attemptId}`)
|
||
.then(res => { if (live && res.data) setQuiz(res.data) })
|
||
.catch(() => { /* The unmarked questions stay on screen, which is honest */ })
|
||
return () => { live = false }
|
||
}, [attemptClosed, attemptId, id])
|
||
|
||
const saveProgressNow = useCallback((overrides = {}) => {
|
||
if (!attemptId || !quizMode) return Promise.resolve()
|
||
return api.post('/attempts/progress', {
|
||
quiz_id: parseInt(id),
|
||
attempt_id: attemptId,
|
||
answers,
|
||
hints,
|
||
current_idx: currentIdx,
|
||
mode: quizMode,
|
||
voice: selectedVoice || null,
|
||
time_left: timeLeft,
|
||
started_at: startedAt,
|
||
total_time: totalTime,
|
||
...overrides,
|
||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||
.then(() => setProgressError(''))
|
||
.catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
|
||
}, [id, answers, hints, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
||
|
||
// Save progress to Redis (survives logout/browser change)
|
||
const saveProgressRef = useRef(null)
|
||
useEffect(() => {
|
||
if (!attemptId || !quizMode) return
|
||
clearTimeout(saveProgressRef.current)
|
||
saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500)
|
||
return () => clearTimeout(saveProgressRef.current)
|
||
}, [saveProgressNow, attemptId, quizMode])
|
||
|
||
useEffect(() => {
|
||
if (!attemptId || !quizMode) return
|
||
const flush = () => { saveProgressNow() }
|
||
const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() }
|
||
window.addEventListener('pagehide', flush)
|
||
document.addEventListener('visibilitychange', flushWhenHidden)
|
||
return () => {
|
||
window.removeEventListener('pagehide', flush)
|
||
document.removeEventListener('visibilitychange', flushWhenHidden)
|
||
}
|
||
}, [attemptId, quizMode, saveProgressNow])
|
||
|
||
// Same rule for the session and per-question clocks: away from the screen is
|
||
// not time spent on the question.
|
||
useEffect(() => {
|
||
if (clockStopped || !attemptId) return undefined
|
||
let tick = null
|
||
const start = () => {
|
||
clearInterval(tick)
|
||
if (document.hidden) return
|
||
tick = setInterval(() => {
|
||
setSessionSeconds(v => v + 1)
|
||
setQuestionSeconds(v => v + 1)
|
||
}, 1000)
|
||
}
|
||
start()
|
||
document.addEventListener('visibilitychange', start)
|
||
return () => {
|
||
clearInterval(tick)
|
||
document.removeEventListener('visibilitychange', start)
|
||
}
|
||
}, [clockStopped, attemptId])
|
||
|
||
// Bank the time on the question you are leaving, then start the next at zero.
|
||
const leavingRef = useRef({ id: null, seconds: 0 })
|
||
leavingRef.current = { id: current?.id, seconds: questionSeconds }
|
||
useEffect(() => {
|
||
const { id, seconds } = leavingRef.current
|
||
return () => {
|
||
if (id && seconds > 0) {
|
||
setQuestionTimes(prev => ({ ...prev, [id]: (prev[id] || 0) + seconds }))
|
||
}
|
||
}
|
||
}, [current?.id])
|
||
useEffect(() => { setQuestionSeconds(0) }, [current?.id])
|
||
|
||
useEffect(() => {
|
||
if (!current?.id) return
|
||
let live = true
|
||
setPanel(null)
|
||
api.get(`/questions/detail/${current.id}/note`)
|
||
.then(res => { if (live) { setNote(res.data?.content || ''); setNoteSaved(true) } })
|
||
.catch(() => { if (live) { setNote(''); setNoteSaved(true) } })
|
||
return () => { live = false }
|
||
}, [current?.id])
|
||
|
||
useEffect(() => {
|
||
api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([]))
|
||
}, [])
|
||
|
||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||
|
||
// Reset one question rather than the whole attempt: a misclick should cost
|
||
// the answer you just gave, not the nineteen before it. A question you only
|
||
// asked to see the answer to is put back the same way, because otherwise one
|
||
// press would close it for the rest of the session.
|
||
const resetQuestion = (questionId) => {
|
||
setAnswers(prev => {
|
||
const next = { ...prev }
|
||
delete next[questionId]
|
||
return next
|
||
})
|
||
setRevealed(prev => {
|
||
if (!prev.has(questionId)) return prev
|
||
const next = new Set(prev)
|
||
next.delete(questionId)
|
||
return next
|
||
})
|
||
setTyped('')
|
||
}
|
||
|
||
/**
|
||
* Show me the answer.
|
||
*
|
||
* The other direction from resetQuestion, and the same shape: it opens one
|
||
* question's answer where that one closes it again. Until now the only way
|
||
* to read an explanation was to choose an option, so a learner who was stuck
|
||
* had to guess first — and a guess entered to unlock the explanation is a
|
||
* wrong answer in the score, in the rail and in every figure the analysis
|
||
* draws afterwards. Asking is not answering, so nothing is recorded.
|
||
*/
|
||
const revealAnswer = (questionId) => {
|
||
setRevealed(prev => {
|
||
if (prev.has(questionId)) return prev
|
||
const next = new Set(prev)
|
||
next.add(questionId)
|
||
return next
|
||
})
|
||
setTyped('')
|
||
}
|
||
|
||
const saveNote = async (questionId, content) => {
|
||
setNoteSaved(false)
|
||
try {
|
||
await api.put(`/questions/detail/${questionId}/note`, { content })
|
||
setNoteSaved(true)
|
||
} catch { setNoteSaved(false) }
|
||
}
|
||
|
||
const saveToFolder = async (collectionId, questionId) => {
|
||
try {
|
||
await api.put(`/collections/${collectionId}/questions/${questionId}`)
|
||
setFolders(list => list.map(f => (f.id === collectionId
|
||
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
|
||
} catch { /* the row stays unticked, which is the honest signal */ }
|
||
}
|
||
|
||
/**
|
||
* Make the folder and put the question in it, in one press.
|
||
*
|
||
* The panel used to say "make one in the question bank" and leave you to go
|
||
* and do it — which means leaving the question you were reading, and coming
|
||
* back to find your place. Naming a folder is the whole of making one.
|
||
*/
|
||
const createFolderWith = async (title, questionId) => {
|
||
const name = title.trim()
|
||
if (!name) return
|
||
setFolderBusy(true)
|
||
try {
|
||
const made = await api.post('/collections/', { title: name })
|
||
await api.put(`/collections/${made.data.id}/questions/${questionId}`)
|
||
setFolders(list => [...list, { ...made.data, question_ids: [questionId] }])
|
||
setFolderQuery('')
|
||
} catch { /* nothing is added, and the field keeps what was typed */ }
|
||
finally { setFolderBusy(false) }
|
||
}
|
||
/**
|
||
* Choosing is answering.
|
||
*
|
||
* Study mode used to hold the choice as a draft and wait for "Submit
|
||
* response" — a second press to confirm something you had already decided,
|
||
* on every question. Clicking an option marks it: green if it was right, red
|
||
* if it was not, with the explanation. Exam mode records it and moves on
|
||
* when you do.
|
||
*
|
||
* A question whose answer is already on screen takes no more answers: after
|
||
* the block is closed, and after Show answer, there is nothing left to
|
||
* decide and anything recorded now would be a copy rather than a response.
|
||
*/
|
||
const chooseAnswer = value => {
|
||
if (!current || attemptClosed) return
|
||
if (isStudy && (answers[current.id] || revealed.has(current.id))) return
|
||
setAnswer(current.id, value)
|
||
}
|
||
|
||
/**
|
||
* Options ruled out.
|
||
*
|
||
* Striking one through is how anybody actually works a five-option question:
|
||
* eliminate, then choose among what is left. It is working-out rather than
|
||
* an answer, so it lives in the page and is not saved — it should not follow
|
||
* you into another sitting of the same question.
|
||
*/
|
||
const [ruledOut, setRuledOut] = useState({})
|
||
const toggleRuledOut = (index) => setRuledOut(prev => {
|
||
const forQuestion = new Set(prev[current.id] || [])
|
||
if (forQuestion.has(index)) forQuestion.delete(index)
|
||
else forQuestion.add(index)
|
||
return { ...prev, [current.id]: [...forQuestion] }
|
||
})
|
||
const isRuledOut = (index) => (ruledOut[current?.id] || []).includes(index)
|
||
|
||
// Free text is the exception: clicking an option is a decision, typing is
|
||
// not, so a typed answer is held until Enter or leaving the field.
|
||
const [typed, setTyped] = useState('')
|
||
const commitTyped = () => {
|
||
if (!current || !typed.trim() || attemptClosed) return
|
||
if (isStudy && (answers[current.id] || revealed.has(current.id))) return
|
||
setAnswer(current.id, typed.trim())
|
||
}
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
setResponseStats(null); setStatsError('')
|
||
// What everybody else picked is worth reading whether the answer was given
|
||
// or asked for — it is the same page of feedback either way.
|
||
if (isStudy && attemptId && current && (answers[current.id] || revealed.has(current.id))) {
|
||
api.get(`/study-tools/attempts/${attemptId}/questions/${current.id}/responses`)
|
||
.then(r => { if (active) setResponseStats(r.data) })
|
||
.catch(() => { if (active) setStatsError('Response statistics are unavailable.') })
|
||
}
|
||
return () => { active = false }
|
||
}, [isStudy, attemptId, current?.id, answers[current?.id], revealed.has(current?.id)])
|
||
|
||
const clearCurrentHighlights = () => {
|
||
if (!current || !manualHighlights[current.id]) return
|
||
setManualHighlights(prev => {
|
||
const next = { ...prev }
|
||
delete next[current.id]
|
||
return next
|
||
})
|
||
}
|
||
|
||
const removeJoinedHighlight = (textId, offset) => {
|
||
if (!current) return
|
||
const [questionKey, fieldKey] = textId.split('::')
|
||
if (questionKey !== String(current.id)) return
|
||
const existing = manualHighlights[current.id]?.[fieldKey] || []
|
||
const joined = existing.find(range => offset >= range.start && offset < range.end)
|
||
if (!joined) return
|
||
setManualHighlights(prev => {
|
||
const questionRanges = prev[current.id] || {}
|
||
const nextRanges = (questionRanges[fieldKey] || []).filter(range => range.start !== joined.start || range.end !== joined.end)
|
||
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||
const next = { ...prev, [current.id]: nextQuestion }
|
||
if (!Object.keys(nextQuestion).length) delete next[current.id]
|
||
return next
|
||
})
|
||
}
|
||
|
||
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
||
|
||
// Only before the answer is in: opening a tip while reading the explanation
|
||
// is revision, and docking it would punish looking things up afterwards.
|
||
const noteHint = useCallback(() => {
|
||
const qid = current?.id
|
||
if (!qid || answers[qid]) return
|
||
setHints(prev => (prev.includes(qid) ? prev : [...prev, qid]))
|
||
}, [current?.id, answers])
|
||
|
||
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
||
|
||
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
||
if (!keepReadThrough) setReadThrough(false)
|
||
setCurrentIdx(targetIdx)
|
||
}
|
||
|
||
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||
if (!attemptId || submitting) return
|
||
if (!autoSubmit && Object.keys(answers).length === 0) {
|
||
showToast('No answers selected — submitting with 0 answered.')
|
||
await new Promise(r => setTimeout(r, 1200))
|
||
}
|
||
setSubmitting(true)
|
||
setSubmitError('')
|
||
try {
|
||
const submission = {
|
||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||
question_id: parseInt(qid), user_answer: answer,
|
||
})),
|
||
// The question still open has not been banked yet; without it the last
|
||
// question of every session would report no time at all.
|
||
timings: {
|
||
...questionTimes,
|
||
...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}),
|
||
},
|
||
hints,
|
||
}
|
||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||
clearInterval(timerRef.current)
|
||
// Handed in, so the block is closed and the player may show its answers.
|
||
// Set here rather than when the clock hit zero because the server only
|
||
// reveals a completed attempt, and it is not completed until this returns.
|
||
setAttemptClosed(true)
|
||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||
// A session ends on its analysis: score, timing and what to do next. The
|
||
// answer-by-answer review is one link from there.
|
||
const target = `/sessions/${attemptId}`
|
||
// A block the clock ended stays where it is. It has just been marked,
|
||
// the player is already showing the answers, and that is the review —
|
||
// the same one a study session gives, on the questions still in front of
|
||
// them. Throwing them onto the analysis page instead would take the
|
||
// paper away at the moment it finally became readable; Exit goes there.
|
||
if (timeUpRef.current) return
|
||
navigate(target, { state: { result: res.data } })
|
||
} catch (err) {
|
||
const detail = err.response?.data?.detail
|
||
setSubmitError(typeof detail === 'string' ? detail : 'Submission failed. Your answers are retained; try again.')
|
||
} finally { setSubmitting(false) }
|
||
}, [attemptId, answers, hints, submitting, navigate, showToast])
|
||
|
||
/**
|
||
* Leave.
|
||
*
|
||
* In study mode that means suspending: the answers are saved, the clock
|
||
* pauses, and you pick it up where you left off.
|
||
*
|
||
* Exam mode suspends too. The clock only runs while the exam is on screen,
|
||
* so leaving stops it rather than spending it — an exam you are not looking
|
||
* at is not an exam you are sitting, and time you were not given the
|
||
* questions for is not time you used.
|
||
*
|
||
* Either way it is one press. Nobody leaves by accident, and nothing is lost.
|
||
*/
|
||
const leaveNow = useCallback(async () => {
|
||
if (attemptId && quizMode) {
|
||
try {
|
||
await api.post('/attempts/progress', {
|
||
quiz_id: parseInt(id),
|
||
attempt_id: attemptId,
|
||
answers,
|
||
hints,
|
||
current_idx: currentIdx,
|
||
mode: quizMode,
|
||
voice: selectedVoice || null,
|
||
time_left: timeLeft,
|
||
started_at: startedAt,
|
||
total_time: totalTime,
|
||
suspended: true,
|
||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||
} catch {
|
||
// Staying put is the safe failure: leaving now would lose the answers.
|
||
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
|
||
return
|
||
}
|
||
}
|
||
navigate(exitTarget())
|
||
}, [attemptId, quizMode, isStudy, id, answers, currentIdx, selectedVoice, timeLeft,
|
||
startedAt, totalTime, navigate, handleSubmit])
|
||
|
||
useEffect(() => {
|
||
if (!quizMode || !current) return
|
||
const keydown = event => {
|
||
if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.('input, textarea, select, [contenteditable="true"]') || document.querySelector('dialog[open]')) return
|
||
if (expandedImagePath) {
|
||
if (event.key === 'Escape') { event.preventDefault(); setExpandedImagePath('') }
|
||
return
|
||
}
|
||
if (event.target.closest?.('button, a') && ['Enter', ' '].includes(event.key)) return
|
||
if (hasActiveTextSelection()) return
|
||
if (/^[1-9]$/.test(event.key) && current.options?.[Number(event.key) - 1] !== undefined) chooseAnswer(current.options[Number(event.key) - 1])
|
||
else if (event.key === 'ArrowLeft') safeNavigate(Math.max(0, currentIdx - 1))
|
||
else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') safeNavigate(Math.min(questions.length - 1, currentIdx + 1))
|
||
else if (event.key.toLowerCase() === 'b') toggleFavorite(current.id)
|
||
else if (event.key === ' ' && current.image_path) { setImageZoom(1); setExpandedImagePath(current.image_path) }
|
||
else return
|
||
event.preventDefault()
|
||
}
|
||
window.addEventListener('keydown', keydown)
|
||
return () => window.removeEventListener('keydown', keydown)
|
||
}, [quizMode, current, currentIdx, answers, favorites, expandedImagePath])
|
||
|
||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||
if (!quiz) return null
|
||
if (resumeError) return <div className="card" role="alert"><p>{resumeError}</p><button type="button" className="btn btn-primary" onClick={() => setResumeRetry(value => value + 1)}>Retry resume</button></div>
|
||
|
||
if (!quizMode) return (
|
||
<div>
|
||
{isModerator && (
|
||
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||
<Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||
</div>
|
||
)}
|
||
{starting ? (
|
||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
||
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz…</div>
|
||
</div>
|
||
) : showOverview ? (
|
||
<div className="qz-overview card">
|
||
<h1>{quiz.title}</h1>
|
||
<p className="qz-overview-meta">
|
||
{quiz.mode === 'timed' ? 'Exam mode' : 'Study mode'}
|
||
{' · '}{quiz.questions_count || quiz.questions_per_attempt} questions
|
||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} minutes` : ''}
|
||
</p>
|
||
<p className="qz-overview-note">
|
||
{quiz.mode === 'timed'
|
||
? 'The clock runs only while the exam is on screen — leave it and it stops.'
|
||
: 'Each answer is marked as you go, with the explanation.'}
|
||
</p>
|
||
<div className="qz-overview-actions">
|
||
<button className="btn btn-primary" onClick={() => {
|
||
setShowOverview(false)
|
||
startQuiz(quiz.mode === 'timed' ? 'exam' : 'study', selectedVoice, null)
|
||
}}>Start session</button>
|
||
<Link className="btn btn-secondary" to="/sessions">Back to history</Link>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="card" style={{ textAlign: 'center' }}>
|
||
<div className="spinner" style={{ margin: '0 auto 16px' }} />
|
||
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Starting…</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
const answeredCount = Object.keys(answers).length
|
||
const totalCount = questions.length
|
||
const isLast = currentIdx === totalCount - 1
|
||
// Reading the block back rather than sitting it. There is nothing left to
|
||
// protect once it is closed, so a finished exam reads like study mode: the
|
||
// rail says what each question was, and the answers are on the page.
|
||
const reviewing = attemptClosed
|
||
// Whether this question's answer is on screen — given, asked for with Show
|
||
// answer, or open to everybody because the block is over. Category and
|
||
// difficulty are hints, so they wait for it too; while an exam is still
|
||
// being sat this is false for every question, which is the whole rule.
|
||
const answerRevealed =
|
||
reviewing || (isStudy && (!!answers[current?.id] || revealed.has(current?.id)))
|
||
// The block chrome — item and block counters, the countdown, Pause and End
|
||
// Block — belongs to a block being sat. Study mode never had any of it, and
|
||
// a closed block has nothing left to pause or hand in.
|
||
const examChrome = !isStudy && !reviewing
|
||
/**
|
||
* Ending the block.
|
||
*
|
||
* In an exam the dialog names how many items are still unanswered before
|
||
* anything is handed in — the one warning worth giving. A study session with
|
||
* everything answered has nothing left to warn about, so it finishes rather
|
||
* than asking a question whose answer is already known.
|
||
*/
|
||
const endBlock = () => (isStudy && answeredCount >= totalCount
|
||
? handleSubmit(false)
|
||
: setShowReview(true))
|
||
|
||
const quizNavigation = (position = 'bottom') => (
|
||
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||
<button className="btn btn-secondary"
|
||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||
disabled={currentIdx === 0}>← Prev</button>
|
||
|
||
{/* The bar is three things: leave, back, on. The question count and the
|
||
list behind it live in the rail and, on a narrow screen, behind the
|
||
site's own menu button — repeating them here crowded the one row
|
||
that has to stay legible at the foot of every question. */}
|
||
|
||
{/* Skip and Next are not the same decision. Moving on from a question
|
||
you have not answered is a choice, and the button says which one it
|
||
is rather than calling both of them Next. In review everything is
|
||
already marked, so there is nothing to skip — it is only a way
|
||
through.
|
||
|
||
On the last question Next carries on being Next. There is nowhere
|
||
further to go, so where it goes is out: it ends the block, which is
|
||
what "next" means at the end of a paper. Disabling it there left the
|
||
hand that had pressed it four times with nothing under it. */}
|
||
<button className="btn btn-primary" disabled={submitting}
|
||
onClick={() => (isLast && !reviewing
|
||
? endBlock()
|
||
: safeNavigate(Math.min(totalCount - 1, currentIdx + 1)))}
|
||
title={isLast && !reviewing
|
||
? (isStudy ? 'Finish this session' : 'End this block') : undefined}>
|
||
{reviewing || answers[current?.id] || isLast ? 'Next →' : 'Skip →'}
|
||
</button>
|
||
|
||
{/* And the end is still its own button, named for what it does. Two
|
||
controls, one outcome, because the one you reach for at the end of a
|
||
block is not always the one you have been pressing all the way
|
||
through it. */}
|
||
{isLast && !reviewing && (
|
||
<button className="btn btn-secondary" disabled={submitting} onClick={endBlock}>
|
||
{isStudy ? 'Finish session' : 'End block'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
|
||
const questionSpeechRange = current
|
||
? getSpeechChunkRange(
|
||
questionStem(current),
|
||
QUESTION_HIGHLIGHT_WORDS,
|
||
activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null,
|
||
)
|
||
: null
|
||
|
||
const toggleFavorite = async (questionId) => {
|
||
const isFavorited = favorites.includes(questionId)
|
||
try {
|
||
if (isFavorited) {
|
||
await api.delete(`/favorites/${questionId}`)
|
||
setFavorites(prev => prev.filter(id => id !== questionId))
|
||
showToast('Removed from favorites')
|
||
} else {
|
||
await api.post('/favorites', { question_id: questionId })
|
||
setFavorites(prev => [...prev, questionId])
|
||
showToast('Added to favorites')
|
||
}
|
||
} catch (err) {
|
||
showToast(err.response?.data?.detail || 'Failed to update favorite')
|
||
}
|
||
}
|
||
|
||
const QuestionRailItem = ({ q, i }) => {
|
||
const isActive = i === currentIdx
|
||
const isDone = !!answers[q.id]
|
||
const marked = favorites.includes(q.id)
|
||
// Only questions the learner has reached show their text. Previewing one
|
||
// they have not opened would give away the case before they read it.
|
||
//
|
||
// While an exam is being sat, none of them do: a real paper's status rail
|
||
// is a column of numbers, and reading the stems still to come is not
|
||
// something the exam being rehearsed would allow. Once it is handed in
|
||
// there is nothing left to protect, so the review reads like study mode.
|
||
const seen = (isStudy || reviewing) ? seenIndexes.has(i) : false
|
||
const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : ''
|
||
return (
|
||
<button type="button"
|
||
className={`quiz-rail-item${isActive ? ' is-active' : ''}${isDone ? ' is-done' : ''}${seen ? '' : ' is-unseen'}${isStudy || reviewing ? '' : ' is-numbers'}`}
|
||
aria-current={isActive ? 'true' : undefined}
|
||
onClick={() => { safeNavigate(i); setNavOpen(false) }}>
|
||
<span className="quiz-rail-num">
|
||
{i + 1}
|
||
{marked && <span className="quiz-rail-mark" aria-label="Marked">★</span>}
|
||
</span>
|
||
{/* One line each. The number already says which question it is, and a
|
||
five-line excerpt makes a rail of twenty into a page of its own. */}
|
||
{seen && <span className="quiz-rail-text">{excerpt}</span>}
|
||
{/* The level, once the question has actually been answered — not
|
||
merely reached. Seeing "easy" beside a question you are still
|
||
working on tells you how hard to look, which is the one thing a
|
||
difficulty mark must not do. */}
|
||
{(isDone || reviewing) && q.difficulty && (
|
||
<Difficulty level={q.difficulty} className="quiz-rail-diff-marks" />
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
|
||
return (
|
||
<div className={`quiz-bottom quiz-player is-boxed${examChrome ? ' is-exam-chrome' : ''}`}>
|
||
{/* The floating global-notes tab is gone. A note taken while sitting a
|
||
question is about that question, and there is a per-question note in
|
||
the toolbar below; a second, unrelated notepad floating over the same
|
||
screen only made it ambiguous which one you were writing in. The
|
||
global note still lives on the dashboard. */}
|
||
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
|
||
{showReview && (() => {
|
||
const missing = questions
|
||
.map((question, index) => ({ question, index }))
|
||
.filter(({ question }) => !answers[question.id])
|
||
return (
|
||
<div className="quiz-away" role="dialog" aria-modal="true"
|
||
aria-labelledby="endblock-heading">
|
||
<div className={`quiz-away-card quiz-endblock${missing.length ? ' is-warning' : ''}`}>
|
||
<h2 id="endblock-heading">
|
||
{missing.length
|
||
? 'Warning - This block is incomplete!'
|
||
: 'End Block'}
|
||
</h2>
|
||
{missing.length ? (
|
||
<>
|
||
<p>Number of unanswered items in this block: {missing.length}</p>
|
||
{/* Said because it is true, not to talk anybody out of it:
|
||
the block can be resumed, and a real paper cannot. */}
|
||
<p>
|
||
You will be able to resume, however we do not recommend this
|
||
as this deviates from your exam day experience.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<p>All {totalCount} questions are answered.</p>
|
||
)}
|
||
<div className="quiz-away-actions">
|
||
<button type="button" className="btn quiz-endblock-go" disabled={submitting}
|
||
onClick={() => { setShowReview(false); handleSubmit(false) }}>
|
||
{submitting ? 'Ending…' : 'End Block'}
|
||
</button>
|
||
<button type="button" className="btn btn-primary"
|
||
onClick={() => setShowReview(false)}>Remain in Block</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
|
||
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
|
||
{fiveLeft && !timeUp && (
|
||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="fivemin-heading">
|
||
<div className="quiz-away-card">
|
||
<h2 id="fivemin-heading">Block Time Warning</h2>
|
||
<p>This block will end in 5 minutes.</p>
|
||
<button type="button" className="btn btn-primary"
|
||
onClick={() => setFiveLeft(false)}>Return to exam</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{timeUp && (
|
||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="timeup-heading">
|
||
<div className="quiz-away-card">
|
||
<h2 id="timeup-heading">Time's Up</h2>
|
||
<p>You have run out of time to complete this question block.</p>
|
||
{/* Closing is what hands it in. Until it is pressed the block is
|
||
simply stopped: nothing has been marked, and a learner who
|
||
comes back to a screen saying this has not already had an
|
||
answer sheet taken from them while they were away. */}
|
||
<button type="button" className="btn btn-primary" disabled={submitting}
|
||
onClick={async () => {
|
||
setTimeUp(false)
|
||
await handleSubmit(true)
|
||
}}>
|
||
{submitting ? 'Marking…' : 'Close'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{clockPaused && !isStudy && (
|
||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="paused-heading">
|
||
<div className="quiz-away-card">
|
||
<h2 id="paused-heading">Exam Paused</h2>
|
||
{/* Nothing else. The clock is stopped and the questions are
|
||
covered; a paragraph about how real exams work is somebody
|
||
else's disclaimer. */}
|
||
<button type="button" className="btn btn-primary"
|
||
onClick={() => setClockPaused(false)}>Return to exam</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Only an exam asks, and it asks the right question. Leaving suspends:
|
||
the answers are saved and the clock stops while the block is off
|
||
screen, so this is worth one word rather than a warning — and it is
|
||
not called "End Session", because nothing ends. Study mode is not
|
||
asked at all; there is nothing there to lose. */}
|
||
{leaving && (
|
||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="leaving-heading">
|
||
<div className="quiz-away-card">
|
||
<h2 id="leaving-heading">Leave this block?</h2>
|
||
<p>
|
||
Your answers are saved and the clock stops while you are away.
|
||
Pick it up where you left off.
|
||
</p>
|
||
<div className="quiz-away-actions">
|
||
<button type="button" className="btn btn-primary"
|
||
onClick={() => { setLeaving(false); leaveNow() }}>Leave</button>
|
||
<button type="button" className="btn btn-secondary"
|
||
onClick={() => setLeaving(false)}>Stay</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Still there? The clock is already stopped by the time this shows —
|
||
it is not a threat, it is how the time stays honest. */}
|
||
{(askingStillHere || away) && (
|
||
<div className="quiz-away" role="dialog" aria-modal="true" aria-labelledby="away-heading">
|
||
<div className="quiz-away-card">
|
||
<h2 id="away-heading">Still there?</h2>
|
||
<p>
|
||
{away
|
||
? 'The clock has been stopped since you stopped. Nothing is lost — pick up where you left off.'
|
||
: 'Nothing has happened for a few minutes, so the clock is stopped. Time you were not at the desk for is not time you spent on the question.'}
|
||
</p>
|
||
<button type="button" className="btn btn-primary" onClick={confirmHere}>
|
||
I'm here — carry on
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{toast && (
|
||
<div style={{
|
||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||
background: 'rgba(15,23,42,0.92)', color: '#f1f5f9',
|
||
padding: '10px 20px', borderRadius: 24, fontSize: '0.85rem',
|
||
zIndex: 500, whiteSpace: 'nowrap', pointerEvents: 'none',
|
||
animation: 'fadeInUp 0.2s ease',
|
||
}}>
|
||
{toast}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="card quiz-header-card" style={{ marginBottom: 6 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||
<div>
|
||
<h2 className="quiz-header-title">{quiz.title}</h2>
|
||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span style={{
|
||
background: isStudy ? '#d1fae5' : '#e0e7ff',
|
||
color: isStudy ? '#065f46' : '#3730a3',
|
||
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
|
||
}}>
|
||
{isStudy ? '📖 Study' : '🎯 Exam'}
|
||
</span>
|
||
{/* The Review badge lives on the rail now, beside the name of
|
||
the session it belongs to. Two of them on one screen, saying
|
||
the same word about the same block, was one too many. */}
|
||
<span>Q {currentIdx + 1} / {totalCount}</span>
|
||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
{/* One clock. While a block is being sat the countdown lives in
|
||
the bar at the foot of the screen, where a paper puts it; this
|
||
is what is left for anything else that runs to a limit. */}
|
||
{timeLeft !== null && !examChrome && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||
{/* Suspend, Restart and Edit were three buttons above a question
|
||
nobody was looking away from to press them. Exit is in the bar
|
||
at the bottom, where the session's own controls are; restarting
|
||
and editing belong to the session list and the editor. */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="progress-bar" style={{ marginBottom: 16 }}>
|
||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||
</div>
|
||
|
||
{/* ── The session, on a phone ────────────────────────────────────
|
||
The desktop keeps the rail permanently beside the question. A phone
|
||
has no room for it, so it is a drawer holding the same list — with
|
||
the site's own menu on the other tab, because the alternative is a
|
||
second hamburger somewhere else for the same purpose. */}
|
||
{!hasRail && navOpen && (
|
||
<div className="quiz-drawer" onClick={e => e.target === e.currentTarget && setNavOpen(false)}>
|
||
<div className="quiz-drawer-panel" role="dialog" aria-modal="true" aria-label="Session">
|
||
<div className="quiz-drawer-head">
|
||
{/* The same burger that opened it closes it: on a phone this
|
||
drawer is what that button does while a session is open. */}
|
||
<button type="button" className="quiz-drawer-close" aria-label="Close"
|
||
onClick={() => setNavOpen(false)}>☰</button>
|
||
<div className="quiz-drawer-tabs" role="tablist">
|
||
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
|
||
onClick={() => setDrawerTab('menu')}>Main menu</button>
|
||
<button type="button" role="tab" aria-selected={drawerTab === 'questions'}
|
||
onClick={() => setDrawerTab('questions')}>Questions</button>
|
||
</div>
|
||
</div>
|
||
|
||
{drawerTab === 'questions' ? (
|
||
<>
|
||
<div className="quiz-drawer-title">
|
||
{/* The block is closed, so there is nothing left to sit;
|
||
what you are doing now is reading it back. */}
|
||
{reviewing && <span className="quiz-drawer-badge">Review</span>}
|
||
<strong>{isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title}</strong>
|
||
<small>{answeredCount}/{totalCount}</small>
|
||
<span className="quiz-drawer-bar" aria-hidden="true">
|
||
<span style={{ width: `${totalCount ? (answeredCount / totalCount) * 100 : 0}%` }} />
|
||
</span>
|
||
</div>
|
||
<div className="quiz-rail-list quiz-drawer-list">
|
||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||
</div>
|
||
{/* How long this is taking, where it is being read — the same
|
||
two figures the desktop shows beside the explanation. */}
|
||
<div className="quiz-drawer-foot">
|
||
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
|
||
answered={answeredCount} paused={clockPaused}
|
||
onTogglePause={() => setClockPaused(v => !v)} />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<nav className="quiz-drawer-menu" aria-label="Main menu">
|
||
{/* Qbank pointed at /questions, which has never been a route —
|
||
/questions/:id is the editor. It went nowhere. */}
|
||
{[
|
||
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/question-bank', 'Qbank'],
|
||
['/collections', 'Collections'], ['/ai', 'AI Mode'],
|
||
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
|
||
['/flashcards', 'Cards'], ['/settings', 'Settings'],
|
||
].map(([to, label]) => (
|
||
<Link key={to} to={to} onClick={() => setNavOpen(false)}>{label}</Link>
|
||
))}
|
||
</nav>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* The question, with the rail on one side and the labs on the other —
|
||
both optional, and the question taking whatever they leave. */}
|
||
<div className={`quiz-layout${railOpen ? '' : ' is-rail-closed'}${labsOpen ? ' has-labs' : ''}`}>
|
||
{/* Rendered whenever the rail is away, and hidden by the same
|
||
breakpoint that hides the rail — the sidebar is a CSS decision, and
|
||
its handle has to be made the same way or the two disagree. */}
|
||
{!railOpen && (
|
||
<button type="button" className="quiz-rail-reopen" aria-label="Show the session questions"
|
||
onClick={() => setRailOpen(true)}>›</button>
|
||
)}
|
||
{/* Main content */}
|
||
<div className="quiz-main">
|
||
<div className="quiz-topbar">
|
||
{examChrome ? (
|
||
<>
|
||
{/* Where you are in the paper, in the paper's own terms. The
|
||
block counter reads 1 of 1 today because a session is one
|
||
block; it is here so that the day it is not, the learner is
|
||
not left counting questions to work out where they are. */}
|
||
<p className="quiz-block-meta">
|
||
<span>Item: <strong>{currentIdx + 1}</strong> of {totalCount}</span>
|
||
<span>Block: <strong>1</strong> of 1</span>
|
||
</p>
|
||
{/* Moving between items is the thing done most often, so it
|
||
sits in the middle with the count between the two arrows
|
||
rather than tucked in beside the tools. */}
|
||
<div className="quiz-item-nav">
|
||
{/* No chevron in the text: the round arrow above each label
|
||
is the chevron, and printing a second one beside it read
|
||
as two controls stuck together. */}
|
||
<button type="button" disabled={currentIdx === 0}
|
||
onClick={() => safeNavigate(currentIdx - 1)}>Previous</button>
|
||
<span className="quiz-item-count">{currentIdx + 1} / {totalCount}</span>
|
||
<button type="button" disabled={submitting}
|
||
onClick={() => (isLast ? endBlock() : safeNavigate(currentIdx + 1))}>Next</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* Where you are, and nothing else. This used to be a button with
|
||
a ☰ on it when there was no rail, which put a second door to
|
||
the list of questions a few pixels below the one in the header
|
||
— and the one in the header is the door that is always there,
|
||
on every page, in the same place. */
|
||
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
|
||
)}
|
||
<div className="quiz-top-actions">
|
||
{/* The exam's own tools, and only the exam's: a study session has
|
||
no clock to beat and no calculator, and its labs are on the
|
||
question's own bar beside the case. The labs open into the
|
||
column next to the question in both modes — the same panel,
|
||
so reference ranges are read against the stem rather than
|
||
over it. */}
|
||
{!isStudy && (
|
||
<>
|
||
<button type="button" className={labsOpen ? 'is-on' : ''}
|
||
title="Lab values" aria-label="Lab values" aria-pressed={labsOpen}
|
||
onClick={() => setLabsOpen(v => !v)}>⚗ <span>Lab values</span></button>
|
||
<button type="button" className={panel === 'note' ? 'is-on' : ''}
|
||
title="Notes" aria-label="Notes" aria-pressed={panel === 'note'}
|
||
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>✎ <span>Notes</span></button>
|
||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||
</>
|
||
)}
|
||
{/* An exam moves between items from the middle of this bar, and
|
||
ends the block from the bar at the foot of it. Repeating
|
||
either here is the same action under a second name. */}
|
||
{/* No arrows here. The bar at the foot of the player is sticky
|
||
and carries Prev and Next already; a second pair above the
|
||
question is the same control twice on one screen. The exam
|
||
chrome keeps its own, because there the foot of the screen is
|
||
the block bar rather than the navigation. */}
|
||
</div>
|
||
</div>
|
||
|
||
{current && (
|
||
<div className="question-card" style={{
|
||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||
}}>
|
||
{/* Difficulty is a hint, so it waits until the answer is in. The
|
||
category trail is gone entirely: it named the answer's own
|
||
topic and led out of a session you are part-way through. What
|
||
to read next belongs in the explanation, which links to it. */}
|
||
<div className="quiz-qmeta">
|
||
{answerRevealed && current.difficulty && (
|
||
<Difficulty level={current.difficulty} />
|
||
)}
|
||
{/* Only when it is not the ordinary kind. A pill reading
|
||
"Multiple choice" above five lettered options is a label
|
||
for something already obvious; True/False and a blank to
|
||
type in are worth saying because they change what you do. */}
|
||
{current.question_type && current.question_type !== 'mcq' && (
|
||
<span className="quiz-meta-pill">
|
||
{current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* A stem carrying a lab table cannot live inside a heading —
|
||
the table would be invalid markup there — so the heading is the
|
||
labelled region and the prose sits inside it. */}
|
||
<div className="quiz-stem" id="quiz-question-heading" role="heading" aria-level={3}>
|
||
<RichText
|
||
value={questionStem(current)}
|
||
textId={`${current.id}::question`}
|
||
highlights={highlightsFor('question')}
|
||
speechRange={questionSpeechRange}
|
||
onRemoveHighlight={removeJoinedHighlight}
|
||
onTipOpen={noteHint}
|
||
/>
|
||
</div>
|
||
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
|
||
{/* Beside the tip rather than up in the toolbar: reference
|
||
ranges are read against the case in front of you, and a
|
||
control at the top of the screen is a different place from
|
||
where the numbers are.
|
||
|
||
Except in an exam, where the chrome across the top is that
|
||
control and repeating it here is the same button twice. */}
|
||
{!examChrome && (
|
||
<button type="button" className={labsOpen ? 'is-on' : ''}
|
||
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
|
||
⚗ <span>Labs</span>
|
||
</button>
|
||
)}
|
||
{current.attending_tip && (
|
||
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
|
||
aria-pressed={panel === 'tip'}
|
||
onClick={() => {
|
||
if (panel !== 'tip') noteHint()
|
||
setPanel(p => (p === 'tip' ? null : 'tip'))
|
||
}}>
|
||
⚕ <span>Attending tip</span>
|
||
</button>
|
||
)}
|
||
<button type="button" className={panel === 'note' ? 'is-on' : ''}
|
||
aria-pressed={panel === 'note'}
|
||
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
|
||
✎ <span>{note ? 'Notes' : 'Add notes'}</span>
|
||
</button>
|
||
{/* Saving, sharing and reporting are occasional, so they fold
|
||
away behind one control rather than each taking a slot in a
|
||
bar that is read on every question. */}
|
||
<MoreActions>
|
||
<button type="button" className="quiz-more-item"
|
||
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
|
||
⊞ Save to a folder
|
||
</button>
|
||
<ShareSession quiz={quiz} firstQuestion={questions[0]?.question_text}
|
||
canManage={!!user && (user.is_moderator || quiz.user_id === user.id)}
|
||
onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
|
||
<details className="quiz-more-feedback">
|
||
<summary>⚑ Give feedback</summary>
|
||
<FeedbackForm questionId={current.id} />
|
||
</details>
|
||
</MoreActions>
|
||
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
|
||
onClick={() => toggleFavorite(current.id)}
|
||
title={favorites.includes(current.id)
|
||
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
|
||
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
|
||
</button>
|
||
{voices.length > 0 && !examChrome && (
|
||
<TTSButton
|
||
key={`${current.id}_${selectedVoice || 'default'}`}
|
||
text={buildQuestionSpeechText(current, currentIdx)}
|
||
voice={selectedVoice}
|
||
segments={getQuestionSpeechSegments(current, currentIdx)}
|
||
getAudio={fetchTtsAudio}
|
||
autoPlay={readThrough}
|
||
onEnded={() => {
|
||
if (readThrough) {
|
||
if (currentIdx < totalCount - 1) {
|
||
safeNavigate(currentIdx + 1, { keepReadThrough: true })
|
||
} else {
|
||
setReadThrough(false)
|
||
}
|
||
return
|
||
}
|
||
}}
|
||
onActiveChange={setTtsActive}
|
||
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
|
||
/>
|
||
)}
|
||
{voices.length > 0 && !examChrome && (
|
||
<button type="button" className={readThrough ? 'is-on' : ''}
|
||
onClick={() => setReadThrough(v => !v)}
|
||
title="Read each question aloud and advance automatically">
|
||
▶ <span>{readThrough ? 'Stop' : 'Listen through'}</span>
|
||
</button>
|
||
)}
|
||
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||
<button type="button" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights}
|
||
disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||
✎ <span>Clear</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The panels sit under the toolbar, where the eye already is, and
|
||
only one opens at a time: two would push the options off screen. */}
|
||
{panel === 'tip' && current.attending_tip && (
|
||
<div className="quiz-panel is-tip">
|
||
<span className="quiz-panel-mark" aria-hidden="true">⚕</span>
|
||
<RichText value={current.attending_tip} />
|
||
</div>
|
||
)}
|
||
|
||
{panel === 'note' && (
|
||
<div className="quiz-panel">
|
||
<textarea className="quiz-note" value={note} rows={4}
|
||
aria-label="Your note on this question"
|
||
placeholder="Your own note on this question — Markdown supported. Only you can see it."
|
||
onChange={e => { setNote(e.target.value); setNoteSaved(false) }}
|
||
onBlur={() => saveNote(current.id, note)} />
|
||
<div className="quiz-panel-foot">
|
||
<span>{noteSaved ? 'Saved' : 'Unsaved'}</span>
|
||
<button type="button" className="btn btn-secondary btn-sm"
|
||
onClick={() => saveNote(current.id, note)}>Save note</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{panel === 'save' && (() => {
|
||
const needle = folderQuery.trim().toLowerCase()
|
||
const found = folders.filter(f => f.title.toLowerCase().includes(needle))
|
||
// Offered only when nothing you already have is called this —
|
||
// two folders of the same name is a filing system nobody can use.
|
||
const canCreate = needle
|
||
&& !folders.some(f => f.title.trim().toLowerCase() === needle)
|
||
return (
|
||
<div className="quiz-panel">
|
||
<p className="quiz-panel-title">Save question in folder</p>
|
||
{/* One box for both. Searching what you have and naming
|
||
what you do not are the same act — you type the name of
|
||
the folder you want and it either exists or it doesn't. */}
|
||
<input className="quiz-folder-find" value={folderQuery}
|
||
placeholder="Create or find folder" aria-label="Create or find folder"
|
||
onChange={e => setFolderQuery(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter' && canCreate) {
|
||
e.preventDefault()
|
||
createFolderWith(folderQuery, current.id)
|
||
}
|
||
}} />
|
||
|
||
{canCreate && (
|
||
<>
|
||
<p className="quiz-folder-heading">Create folder</p>
|
||
<button type="button" className="quiz-folder-new" disabled={folderBusy}
|
||
onClick={() => createFolderWith(folderQuery, current.id)}>
|
||
<span>{folderQuery.trim()}</span>
|
||
<span aria-hidden="true">+</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{found.length > 0 && (
|
||
<ul className="quiz-folders">
|
||
{found.map(folder => {
|
||
const inIt = (folder.question_ids || []).includes(current.id)
|
||
return (
|
||
<li key={folder.id}>
|
||
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
|
||
onClick={() => saveToFolder(folder.id, current.id)}>
|
||
{inIt ? '✓ ' : '+ '}{folder.title}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
|
||
{!found.length && !canCreate && (
|
||
<p className="quiz-panel-empty">
|
||
{folders.length ? 'No folder by that name.' : 'Type a name to make your first folder.'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Figures are rows now. The legacy single path is still shown
|
||
for anything that was never backfilled. */}
|
||
{current.figures?.some(f => f.role === 'stem') ? (
|
||
<FigureStrip figures={current.figures.filter(f => f.role === 'stem')}
|
||
attemptId={attemptId} size="full" />
|
||
) : current.image_path && (
|
||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
||
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
|
||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||
</button>
|
||
)}
|
||
{expandedImagePath && (
|
||
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
|
||
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
|
||
<button type="button" onClick={() => adjustImageZoom(-0.1)} disabled={imageZoom <= 1}>-</button>
|
||
<span>{Math.round(imageZoom * 100)}%</span>
|
||
<button type="button" onClick={() => adjustImageZoom(0.1)} disabled={imageZoom >= 4}>+</button>
|
||
{[1, 2, 3, 4].map(zoom => (
|
||
<button key={zoom} type="button" onClick={() => setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x</button>
|
||
))}
|
||
</div>
|
||
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
||
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
|
||
<img
|
||
src={uploadUrl(expandedImagePath, attemptId)}
|
||
alt="Expanded question illustration"
|
||
style={{
|
||
maxWidth: imageZoom === 1 ? 'min(100%, 1100px)' : 'none',
|
||
maxHeight: imageZoom === 1 ? '92vh' : 'none',
|
||
width: imageZoom > 1 ? `${imageZoom * 100}%` : undefined,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||
<div className="options" style={{ marginTop: 8 }}>
|
||
{current.options.map((opt, i) => {
|
||
const isSelected = answers[current.id] === opt
|
||
// Marked, which is not the same as answered: Show answer
|
||
// and a closed block both mark a question nobody chose an
|
||
// option on.
|
||
const marked = answerRevealed
|
||
const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
|
||
const showCorrect = marked && isCorrectOpt
|
||
const showWrong = marked && isSelected && !isCorrectOpt
|
||
const letter = optionLetter(i)
|
||
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
||
? activeReadSegment.chunkIndex
|
||
: null
|
||
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||
const optionFieldKey = `option-${i}`
|
||
return (
|
||
<div key={i} className="option-row">
|
||
<button type="button" aria-pressed={isSelected} aria-disabled={marked}
|
||
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''} ${isRuledOut(i) ? 'ruled-out' : ''}`}
|
||
onClick={() => {
|
||
if (hasActiveTextSelection()) return
|
||
if (!marked) return chooseAnswer(opt)
|
||
// Once answered, the option is a disclosure for its
|
||
// own reasoning: clicking it opens that, and clicking
|
||
// it again closes it.
|
||
if (current.option_explanations?.[opt]) toggleOptionExplanation(i)
|
||
}}
|
||
style={{
|
||
cursor: marked && current.option_explanations?.[opt] ? 'pointer' : marked ? 'default' : 'pointer',
|
||
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
|
||
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
|
||
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
||
}}>
|
||
<span className="option-letter">{letter}</span>
|
||
<span className="option-text">
|
||
<RichText
|
||
value={opt}
|
||
className="rich-inline"
|
||
textId={`${current.id}::${optionFieldKey}`}
|
||
highlights={highlightsFor(optionFieldKey)}
|
||
speechRange={optionSpeechRange}
|
||
onRemoveHighlight={removeJoinedHighlight}
|
||
onTipOpen={noteHint}
|
||
/>
|
||
{showStats && responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
|
||
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>
|
||
<span>{responseStats.options[i].percentage}%</span><span>{responseStats.options[i].count}/{responseStats.sample_size}</span>
|
||
</span>}
|
||
</span>
|
||
{showCorrect && <span className="option-status option-status-correct">✓ Correct</span>}
|
||
{showWrong && <span className="option-status option-status-wrong">✗ Wrong</span>}
|
||
{marked && (showAllExplanations || openExplanations.has(i)) && current.option_explanations?.[opt] && (
|
||
<span className="quiz-option-explanation">
|
||
<RichText value={current.option_explanations[opt]} className="rich-inline" />
|
||
</span>
|
||
)}
|
||
</button>
|
||
{/* The topic behind the right answer, offered where the
|
||
learner is already looking. Outside the option's
|
||
button rather than inside it, because a link nested
|
||
in a button is neither: the click would toggle the
|
||
explanation instead of opening the article. */}
|
||
{showCorrect && (
|
||
<QuestionReadingLinks questionId={current.id} variant="chips" />
|
||
)}
|
||
{/* Outside the option, so ruling one out is never
|
||
mistaken for choosing it. Gone once the question is
|
||
marked — there is nothing left to narrow down. */}
|
||
{!marked && (
|
||
<button type="button" className="option-rule-out"
|
||
aria-pressed={isRuledOut(i)}
|
||
aria-label={`${isRuledOut(i) ? 'Bring back' : 'Rule out'} option ${letter}`}
|
||
onClick={() => toggleRuledOut(i)}>ab</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<input type="text" placeholder="Type your answer, then press Enter"
|
||
value={answers[current.id] ?? typed}
|
||
readOnly={answerRevealed}
|
||
onChange={e => setTyped(e.target.value)}
|
||
onBlur={commitTyped}
|
||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitTyped() } }}
|
||
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||
)}
|
||
{/* Being stuck is a reason to read the explanation, not a reason
|
||
to guess. The only way to see the answer used to be to pick
|
||
an option, so a learner who did not know had to enter
|
||
something — and a guess made to unlock the explanation is a
|
||
wrong answer in the score, in the rail and in every figure
|
||
the analysis draws from them afterwards. An exam is never
|
||
offered this: there is nothing to show until it is over. */}
|
||
{isStudy && !answerRevealed && (
|
||
<div className="quiz-reveal">
|
||
<button type="button" className="quiz-reveal-button"
|
||
onClick={() => revealAnswer(current.id)}>Show answer</button>
|
||
</div>
|
||
)}
|
||
{answerRevealed && (
|
||
<>
|
||
<div className="quiz-answer-bar">
|
||
<p className="quiz-stats-note">
|
||
{!responseStats ? ''
|
||
: showStats ? (responseStats.sample_size
|
||
? `Based on ${responseStats.sample_size} recorded answers`
|
||
: 'No response statistics yet')
|
||
: 'Response statistics hidden'}
|
||
</p>
|
||
<div className="quiz-answer-acts">
|
||
<button type="button" className="quiz-stats-toggle"
|
||
onClick={() => resetQuestion(current.id)}
|
||
disabled={!answers[current.id] && !revealed.has(current.id)}>
|
||
↺ Reset question
|
||
</button>
|
||
<button type="button" className="quiz-stats-toggle" aria-pressed={showStats} onClick={toggleStats}>
|
||
{showStats ? 'Hide stats' : 'Show stats'}
|
||
</button>
|
||
{Object.keys(current.option_explanations || {}).length > 0 && (
|
||
<button type="button" className="quiz-stats-toggle" aria-pressed={showAllExplanations} onClick={() => setShowAllExplanations(v => !v)}>
|
||
{showAllExplanations ? 'Hide explanations' : 'Show all explanations'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{statsError && <p className="quiz-stats-note">{statsError}</p>}
|
||
{(current.explanation || current.explanation_image_path) && (
|
||
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||
{current.explanation && <><strong>Explanation:</strong> <RichText value={current.explanation} /></>}
|
||
{/* Labelled thumbnails, so the prose can say "as in
|
||
Figure 2" and mean something. */}
|
||
{current.figures?.some(f => f.role === 'explanation') && (
|
||
<FigureStrip figures={current.figures.filter(f => f.role === 'explanation')}
|
||
attemptId={attemptId} size="compact" label="Figures" />
|
||
)}
|
||
{!current.figures?.some(f => f.role === 'explanation') && current.explanation_image_path && (
|
||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
|
||
<img src={uploadUrl(current.explanation_image_path, attemptId)} alt="Explanation illustration"
|
||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Gated on key points, not on option explanations. It asked
|
||
whether the *other* field was populated, so a question
|
||
with key points and no per-option reasoning showed none of
|
||
them — which is most of the bank, once anybody writes
|
||
them. */}
|
||
{(current.key_points || []).length > 0 && (
|
||
<div className="explanation" style={{ marginTop: 16 }}>
|
||
<strong>Key points</strong>
|
||
<ul className="quiz-key-points">
|
||
{(current.key_points || []).map((point, i) => (
|
||
<li key={i}>
|
||
{point.text}
|
||
{point.article_id && (
|
||
<Link to={`/articles/${point.article_id}${point.article_section_id ? `?section=${point.article_section_id}` : ''}`} className="quiz-key-point-link">📖 Read more</Link>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{current.question_type === 'fill_blank' && (
|
||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||
<strong>Correct Answer:</strong> {current.correct_answer}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{labsOpen && (
|
||
<aside className="quiz-labpanel" aria-label="Lab values">
|
||
<div className="quiz-labpanel-head">
|
||
<strong>Lab values</strong>
|
||
<button type="button" aria-label="Close lab values"
|
||
onClick={() => setLabsOpen(false)}>✕</button>
|
||
</div>
|
||
<div className="quiz-labpanel-body"><LabValues /></div>
|
||
</aside>
|
||
)}
|
||
|
||
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
|
||
<div className="quiz-sidebar quiz-rail">
|
||
<div className="quiz-rail-head">
|
||
<span className="quiz-rail-name">
|
||
{reviewing && <b className="quiz-rail-badge">Review</b>}
|
||
{/* Which session this is, in full. A repetition is titled
|
||
"… (repetition)" when it is built, and that is worth seeing:
|
||
it is the difference between a score that counts towards
|
||
what you know and one that is practice. */}
|
||
<strong>{isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title}</strong>
|
||
<small>{answeredCount} / {totalCount}</small>
|
||
{/* How much is behind you, without being read. */}
|
||
<span className="quiz-rail-progress" aria-hidden="true">
|
||
<span style={{ width: `${totalCount ? (answeredCount / totalCount) * 100 : 0}%` }} />
|
||
</span>
|
||
</span>
|
||
<button type="button" className="quiz-rail-hide" aria-label="Hide the session questions"
|
||
onClick={() => setRailOpen(false)}>‹</button>
|
||
</div>
|
||
<div className="quiz-rail-list">
|
||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||
</div>
|
||
{/* The clocks live here, at the foot of the rail with everything else
|
||
that is about the session rather than about this question. They
|
||
used to sit in a grey strip across the top of the explanation,
|
||
where they read as part of the answer. */}
|
||
<div className="quiz-rail-foot">
|
||
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
|
||
answered={answeredCount} paused={clockPaused}
|
||
onTogglePause={() => setClockPaused(v => !v)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* The session's own bar, outside the scrolling columns so it is always
|
||
on screen — the player is a fixed-height shell and the question
|
||
scrolls inside it, rather than the whole page scrolling. */}
|
||
<div className={`quiz-footbar${examChrome ? ' is-exam' : ''}`}>
|
||
{examChrome ? (
|
||
<>
|
||
<div className="quiz-footbar-left">
|
||
<button type="button" className="quiz-block-btn quiz-exit"
|
||
onClick={() => setLeaving(true)}>
|
||
<span aria-hidden="true">⏻</span> Exit session
|
||
</button>
|
||
{/* The block's own clock, and the only one: a second countdown
|
||
elsewhere on the screen is a second chance to misread it. An
|
||
untimed block says so rather than showing an empty space
|
||
where the figure everybody looks for should be. */}
|
||
<span className="quiz-block-time">
|
||
Block Time Remaining: <strong>{timeLeft === null ? 'Untimed' : blockClock(timeLeft)}</strong>
|
||
</span>
|
||
</div>
|
||
{/* Pause is the exam's own pause — the same flag the clocks read,
|
||
and the same Exam Paused dialog that covers the questions. */}
|
||
<button type="button" className="quiz-block-btn quiz-block-pause"
|
||
onClick={() => setClockPaused(true)}>
|
||
<span aria-hidden="true">❚❚</span> Pause
|
||
</button>
|
||
<button type="button" className="quiz-block-btn quiz-block-end" disabled={submitting}
|
||
onClick={() => setShowReview(true)}>
|
||
<span aria-hidden="true">⊗</span> End Block
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
|
||
onClick={leaveNow}>Exit session</button>
|
||
{quizNavigation('bottom')}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* The tutor is study-mode only — it is given the correct answer and
|
||
told it may explain it — and an administrator can withhold it from
|
||
study sessions too. Both are enforced on the server; this only
|
||
decides whether the button is there to press. */}
|
||
{isStudy && tutorAllowed && current && (
|
||
<Suspense fallback={null}>
|
||
<TeachChat question={current} attemptId={attemptId} />
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|