import { uploadUrl } from '../utils/uploads'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import MyNote from '../components/MyNote'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
const TeachChat = lazy(() => 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 mergeTextRanges(ranges) {
const sorted = ranges
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
.sort((a, b) => a.start - b.start || a.end - b.end)
const merged = []
sorted.forEach(range => {
const last = merged[merged.length - 1]
if (!last || range.start > last.end) {
merged.push({ start: range.start, end: range.end })
} else {
last.end = Math.max(last.end, range.end)
}
})
return merged
}
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 ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) {
const ranges = mergeTextRanges(highlights)
const boundaries = new Set([0, (text || '').length])
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) }
const points = [...boundaries]
.filter(point => point >= 0 && point <= (text || '').length)
.sort((a, b) => a - b)
return points.slice(0, -1).map((start, i) => {
const end = points[i + 1]
if (end <= start) return null
const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end)
const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end
const className = [
'manual-highlight-segment',
manuallyHighlighted ? 'manual-highlight-active' : '',
speechHighlighted ? 'speech-highlight-active' : '',
].filter(Boolean).join(' ')
return (
{
event.preventDefault()
onRemoveHighlight?.(textId, start)
} : undefined}
title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
>
{text.slice(start, end)}
)
})
}
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 (
{label}
)
}
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 (
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
)
}
function QuizCodeBadge({ code }) {
if (!code) return null
const copyCode = async () => {
try {
await navigator.clipboard.writeText(String(code))
} catch { }
}
return (
Quiz PIN
{code}
Copy
)
}
function CourseQuizStart({ quiz, onStart }) {
const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
const [error, setError] = useState('')
const [starting, setStarting] = useState(false)
const begin = async () => {
setError('')
setStarting(true)
try {
await onStart(mode, '', quiz.time_limit_minutes || null)
} catch {
setError('Could not start the quiz. Try again.')
setStarting(false)
}
}
return (
{quiz.title}
{quiz.questions_per_attempt || quiz.questions_count} questions
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` ยท ${quiz.time_limit_minutes} min time limit` : ''}
{mode === 'exam'
? 'This is a timed exam โ answers are hidden until you submit.'
: 'Study mode โ answers and explanations shown as you go.'}
{error && (
{error}
)}
{starting ? 'Starting...' : 'Begin Quiz'}
)
}
function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('')
const [startingMode, setStartingMode] = useState('')
const handleStart = async (mode) => {
if (mode === 'exam' && customTimer && (!Number.isInteger(Number(customTimer)) || Number(customTimer) < 1)) {
setStartError('Enter a positive whole number of minutes, or leave the timer blank.')
return
}
const timerMinutes = mode === 'exam' && customTimer ? Number(customTimer) : null
setStartError('')
setStartingMode(mode)
try {
await onStart(mode, selectedVoice, timerMinutes)
} catch {
setStartError('Could not start the quiz. Try again.')
} finally {
setStartingMode('')
}
}
return (
๐
{quiz.title}
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` ยท ${quiz.time_limit_minutes} min limit` : ''}
Choose how to take this quiz:
{[
{ mode: 'study', icon: '๐', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '๐ฏ', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => (
!startingMode && handleStart(mode)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
{icon}
{label}
{startingMode === mode ? 'Starting...' : desc}
))}
{startError && (
{startError}
)}
{voices.length > 0 && (
๐ Voice for read-aloud
setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => {v.name}{v.is_default ? ' (default)' : ''} )}
)}
)
}
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()
const [searchParams] = useSearchParams()
const returnTo = searchParams.get('return_to')
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 [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
const [manualHighlights, setManualHighlights] = useState({})
const [draftAnswer, setDraftAnswer] = useState('')
const [tool, setTool] = useState(null)
const [showReview, setShowReview] = useState(false)
const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('')
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)
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])
const [leaveTarget, setLeaveTarget] = useState(null)
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
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)
setDraftAnswer('')
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)
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)
// Restore timer โ calculate remaining from started_at + total_time
if (saved.total_time && saved.started_at) {
const elapsed = Math.floor((new Date() - new Date(saved.started_at)) / 1000)
const remaining = Math.max(0, saved.total_time - elapsed)
setTimeLeft(remaining)
setTotalTime(saved.total_time)
}
}, [id])
// Warn before tab/window close when mid-quiz
useEffect(() => {
if (!attemptId) return
const msg = progressError || (timeLeft !== null
? 'Your quiz is timed. Closing the tab leaves its timer running. Progress is saved while connected.'
: '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
try {
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)
}
} 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])
const startQuiz = async (mode, voice, timerMinutes = null) => {
if (hasStarted.current || resumeError || loading) return
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}`)
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 timerStarted = timeLeft !== null
useEffect(() => {
if (!timerStarted) return
timerRef.current = setInterval(() => {
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
}, 1000)
return () => clearInterval(timerRef.current)
}, [timerStarted])
// Auto-submit when timer reaches zero
useEffect(() => {
if (timeLeft === 0) handleSubmit(true)
}, [timeLeft])
const saveProgressNow = useCallback((overrides = {}) => {
if (!attemptId || !quizMode) return Promise.resolve()
return api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
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, 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])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const chooseAnswer = value => {
if (!current || (isStudy && answers[current.id])) return
if (isStudy) setDraftAnswer(value)
else setAnswer(current.id, value)
}
const submitStudyResponse = () => {
if (isStudy && current && !answers[current.id] && draftAnswer.trim()) setAnswer(current.id, draftAnswer)
}
useEffect(() => {
let active = true
setResponseStats(null); setStatsError('')
if (isStudy && attemptId && current && answers[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]])
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] || []
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,
})),
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
if (returnTo) {
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
} else {
navigate(`/results/${attemptId}`, { 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, submitting, navigate, showToast])
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 === 'Enter' && isStudy) submitStudyResponse()
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, draftAnswer, favorites, expandedImagePath])
if (loading) return
if (!quiz) return null
if (resumeError) return {resumeError}
setResumeRetry(value => value + 1)}>Retry resume
if (!quizMode) return (
{isModerator && (
โ๏ธ Edit Questions
)}
{starting ? (
) : returnTo ? (
) : (
)}
)
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
const quizCode = quiz.quiz_code || quiz.id || id
const quizNavigation = (position = 'bottom') => (
safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}>โ Prev
setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? 'โผ' : 'โฒ'}
{isLast ? (
setShowReview(true)} disabled={submitting}>
Review & Complete
) : (
safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next โ
)}
)
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 QuestionDot = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
return (
{ safeNavigate(i); setNavOpen(false) }} style={{
width: 34, height: 34, borderRadius: '50%', border: isActive ? '2px solid var(--primary)' : 'none',
cursor: 'pointer', fontSize: '0.78rem', fontWeight: 600, flexShrink: 0,
background: isActive ? 'var(--primary)' : isDone ? 'var(--correct-bg)' : 'var(--border)',
color: isActive ? 'white' : isDone ? 'var(--correct-fg)' : 'var(--text-muted)',
transition: 'background 0.1s',
}}>{i + 1}
)
}
return (
{tool &&
setTool(null)} />}
{showReview && setShowReview(false)}>
{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.
{questions.map((question, index) => { safeNavigate(index); setShowReview(false) }}>
{index + 1} ยท {answers[question.id] ? 'Answered' : 'Unanswered'}{favorites.includes(question.id) ? ' ยท Bookmarked' : ''}
)}
{ setShowReview(false); handleSubmit(false) }}>Complete test
}
{submitError && {submitError} handleSubmit(false)}>Retry submission
}
{progressError && {progressError} saveProgressNow()}>Retry saving
}
{/* In-app leave confirmation */}
{leaveTarget && (
โธ
Suspend quiz?
We will save your current answers before leaving, so you can resume from here.
{timeLeft !== null && (
<>
Timer will pause while you are away and resume when you return.
Note: closing the tab without suspending leaves the timer running, and it may auto-submit if time runs out.
>
)}
setLeaveTarget(null)}>Stay
{
// Save with suspended=true so the timer pauses on the server side
if (attemptId && quizMode) {
try {
await api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
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 {
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
setLeaveTarget(null)
return
}
}
const target = leaveTarget
setLeaveTarget(null)
navigate(target)
}}>Suspend & Leave
)}
{toast && (
{toast}
)}
{/* Header */}
{quiz.title}
{isStudy ? '๐ Study' : '๐ฏ Exam'}
Q {currentIdx + 1} / {totalCount}
{answeredCount} answered
{timeLeft !== null && }
setLeaveTarget(returnTo || '/')} title="Save progress and exit">
โธ Suspend
{isModerator && โ๏ธ Edit}
{voices.length > 1 && (
๐
setSelectedVoice(e.target.value)}
disabled={ttsActive}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.78rem', opacity: ttsActive ? 0.5 : 1, background: 'var(--input-bg)', color: 'var(--text)' }}>
{voices.map(v => {v.name} )}
)}
{/* Two-column layout: content + desktop sidebar */}
{/* Main content */}
setNavOpen(value => !value)}>Question {currentIdx + 1} of {totalCount} โพ
setTool('shortcuts')}>โจ Shortcuts
setTool('calculator')}>โฆ Calculator
setTool('labs')}>โ Lab values
setShowReview(true)}>Review & Complete
safeNavigate(currentIdx - 1)}>โน
safeNavigate(currentIdx + 1)}>Next โบ
{navOpen &&
{questions.map((q, i) => )}
}
{current && (
{current.category_breadcrumbs?.length ? current.category_breadcrumbs.map((category, index) => {index > 0 && โบ } {category.name} ) : Uncategorized }
toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.4rem',
padding: 4,
lineHeight: 1,
transition: 'transform 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.15)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
{favorites.includes(current.id) ? 'โญ' : 'โ'}
{voices.length > 0 && (
{
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 && (
setReadThrough(v => !v)}
title="Read each question aloud and advance automatically"
>
{readThrough ? 'Stop listen-through' : 'Listen through'}
)}
e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
Clear
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
{current.image_path && (
{ setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
e.currentTarget.closest('button').style.display = 'none'} />
)}
{expandedImagePath && (
setExpandedImagePath('')}>
e.stopPropagation()}>
adjustImageZoom(-0.1)} disabled={imageZoom <= 1}>-
{Math.round(imageZoom * 100)}%
adjustImageZoom(0.1)} disabled={imageZoom >= 4}>+
{[1, 2, 3, 4].map(zoom => (
setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x
))}
setExpandedImagePath('')} type="button" aria-label="Close expanded image">ร
e.stopPropagation()}>
1 ? `${imageZoom * 100}%` : undefined,
}}
/>
)}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
{current.options.map((opt, i) => {
const isSelected = (answers[current.id] || draftAnswer) === opt
const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = i + 1
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 (
!hasAnswered && !hasActiveTextSelection() && chooseAnswer(opt)}
style={{
cursor: hasAnswered ? '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',
}}>
{letter}
{responseStats?.sample_size > 0 && responseStats.options?.[i] &&
{responseStats.options[i].percentage}% {responseStats.options[i].count}/{responseStats.sample_size}
}
{showCorrect && โ Correct }
{showWrong && โ Wrong }
)
})}
) : (
chooseAnswer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && isStudy) { e.preventDefault(); submitStudyResponse() } }}
style={{ marginTop: 10, width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} />
)}
{isStudy && !answers[current.id] &&
Submit response }
{isStudy && answers[current.id] && (
<>
Preferred response {current.page_reference && Source page {current.page_reference} }
{responseStats &&
{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}
}
{statsError &&
{statsError}
}
{(current.explanation || current.explanation_image_path) && (
{current.explanation && <>
Explanation: {current.explanation}>}
{current.explanation_image_path && (
{ setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
e.currentTarget.closest('button').style.display = 'none'} />
)}
)}
{current.question_type === 'fill_blank' && (
Correct Answer: {current.correct_answer}
)}
>
)}
)}
{quizNavigation('bottom')}
{answeredCount > 0 && !isLast && (
setShowReview(true)} disabled={submitting}>
Review ({answeredCount}/{totalCount} answered)
)}
{/* Desktop sidebar */}
Questions
{questions.map((q, i) => )}
{answeredCount} answered
{totalCount - answeredCount} remaining
{answeredCount > 0 && !isLast && (
setShowReview(true)} disabled={submitting}>
Review & Complete
)}
{/* AI tutor โ only in study mode, lazy-loaded */}
{isStudy && current && (
)}
)
}