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 ( ) } 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}
) } 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}
)}
) } 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 }) => ( ))}
{startError && (
{startError}
)}
setCustomTimer(e.target.value)} placeholder="No time limit" onClick={e => e.stopPropagation()} style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border)', fontSize: '0.9rem', width: '100%', background: 'var(--input-bg)', color: 'var(--text)' }} />

Auto-submits when timer expires. Timer pauses if you leave and resumes when you come back.

{voices.length > 0 && (
)}
) } 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
Loading quiz...
if (!quiz) return null if (resumeError) return

{resumeError}

if (!quizMode) return (
{isModerator && (
โœ๏ธ Edit Questions
)} {starting ? (
Loading quizโ€ฆ
) : 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') => (
{isLast ? ( ) : ( )}
) 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 ( ) } return (
{tool && setTool(null)} />} {showReview && setShowReview(false)}>

{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.

{questions.map((question, index) => )}
} {submitError &&
{submitError}
} {progressError &&
{progressError}
} {/* 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. )}

)} {toast && (
{toast}
)} {/* Header */}

{quiz.title}

{isStudy ? '๐Ÿ“– Study' : '๐ŸŽฏ Exam'} Q {currentIdx + 1} / {totalCount} {answeredCount} answered
{timeLeft !== null && } {isModerator && โœ๏ธ Edit}
{voices.length > 1 && (
)}
{/* Two-column layout: content + desktop sidebar */}
{/* Main content */}
{navOpen &&
{questions.map((q, i) => )}
} {current && (

{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 && ( )}
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'} {current.image_path && ( )} {expandedImagePath && (
setExpandedImagePath('')}>
e.stopPropagation()}> {Math.round(imageZoom * 100)}% {[1, 2, 3, 4].map(zoom => ( ))}
e.stopPropagation()}> Expanded question illustration 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 ( ) })}
) : ( 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] && } {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 && ( )}
)} {current.question_type === 'fill_blank' && (
Correct Answer: {current.correct_answer}
)} )}
)} {quizNavigation('bottom')} {answeredCount > 0 && !isLast && (
)}
{/* Desktop sidebar */}
Questions
{questions.map((q, i) => )}
{answeredCount} answered {totalCount - answeredCount} remaining
{answeredCount > 0 && !isLast && ( )}
{/* AI tutor โ€” only in study mode, lazy-loaded */} {isStudy && current && ( )}
) }