pdf-quiz-generator/frontend/src/pages/QuizPage.jsx
Daniel 1ce3eec7cb feat: authorize tutor context and protect uploaded media
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
2026-09-07 15:04:00 +02:00

1327 lines
62 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<span
key={`${start}-${end}`}
className={className}
data-manual-highlight-id={textId}
data-start={start}
data-end={end}
onContextMenu={manuallyHighlighted ? (event) => {
event.preventDefault()
onRemoveHighlight?.(textId, start)
} : undefined}
title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
>
{text.slice(start, end)}
</span>
)
})
}
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>
)
}
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>
)
}
function QuizCodeBadge({ code }) {
if (!code) return null
const copyCode = async () => {
try {
await navigator.clipboard.writeText(String(code))
} catch { }
}
return (
<div className="quiz-code-badge" title="Share this PIN/code to identify the quiz">
<span>Quiz PIN</span>
<code>{code}</code>
<button type="button" className="btn btn-secondary btn-sm" onClick={copyCode}>Copy</button>
</div>
)
}
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 (
<div style={{ maxWidth: 480, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
<QuizCodeBadge code={quiz.quiz_code || quiz.id} />
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
{quiz.questions_per_attempt || quiz.questions_count} questions
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
</p>
<p style={{ fontSize: '0.88rem', marginBottom: 20 }}>
{mode === 'exam'
? 'This is a timed exam — answers are hidden until you submit.'
: 'Study mode — answers and explanations shown as you go.'}
</p>
{error && (
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
{error}
</div>
)}
<button className="btn btn-primary" onClick={begin} disabled={starting}>
{starting ? 'Starting...' : 'Begin Quiz'}
</button>
</div>
</div>
)
}
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 (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
<QuizCodeBadge code={quiz.quiz_code || quiz.id} />
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</p>
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
{ 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 }) => (
<button type="button" key={mode} onClick={() => !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'}
>
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
</button>
))}
</div>
{startError && (
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
{startError}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
<input type="number" min={1} value={customTimer}
onChange={e => 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)' }} />
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>Auto-submits when timer expires. Timer pauses if you leave and resumes when you come back.</p>
</div>
{voices.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
</select>
</div>
)}
</div>
</div>
)
}
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 <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={`/quizzes/${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>
) : returnTo ? (
<CourseQuizStart quiz={quiz} onStart={startQuiz} />
) : (
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
)}
</div>
)
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') => (
<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>
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
onClick={() => setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
</button>
{isLast ? (
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
Review & Complete
</button>
) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </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 QuestionDot = ({ q, i }) => {
const isActive = i === currentIdx
const isDone = !!answers[q.id]
return (
<button key={q.id} onClick={() => { 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}</button>
)
}
return (
<div className="quiz-bottom quiz-player">
<MyNote variant="tab" />
{tool && <QuizTools tool={tool} onClose={() => setTool(null)} />}
{showReview && <QuizDialog title="Review & Complete" onClose={() => setShowReview(false)}>
<p>{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.</p>
<div className="quiz-review-grid">{questions.map((question, index) => <button type="button" key={question.id} onClick={() => { safeNavigate(index); setShowReview(false) }}>
{index + 1} · {answers[question.id] ? 'Answered' : 'Unanswered'}{favorites.includes(question.id) ? ' · Bookmarked' : ''}
</button>)}</div>
<button type="button" className="quiz-complete-confirm" disabled={submitting} onClick={() => { setShowReview(false); handleSubmit(false) }}>Complete test</button>
</QuizDialog>}
{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>}
{/* In-app leave confirmation */}
{leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 420, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
We will save your current answers before leaving, so you can resume from here.
{timeLeft !== null && (
<><br/><br/>
<strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
<span style={{ fontSize: '0.8rem' }}>
Note: closing the tab without suspending leaves the timer running, and it may auto-submit if time runs out.
</span>
</>
)}
</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
<button className="btn btn-primary" onClick={async () => {
// 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 &amp; Leave</button>
</div>
</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: 14 }}>
<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>
<span>Q {currentIdx + 1} / {totalCount}</span>
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
<QuizCodeBadge code={quizCode} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
Suspend
</button>
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div>
</div>
{voices.length > 1 && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ fontSize: '0.75rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊</label>
<select value={selectedVoice} onChange={e => 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 => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
)}
</div>
<div className="progress-bar" style={{ marginBottom: 16 }}>
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{/* Two-column layout: content + desktop sidebar */}
<div className="quiz-layout">
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="quiz-topbar">
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} </button>
<div className="quiz-top-actions">
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}> <span>Shortcuts</span></button>
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}> <span>Calculator</span></button>
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}> <span>Lab values</span></button>
<button type="button" className="quiz-review-button" onClick={() => setShowReview(true)}>Review & Complete</button>
<button type="button" aria-label="Previous question" disabled={currentIdx === 0} onClick={() => safeNavigate(currentIdx - 1)}></button>
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next </button>
</div>
</div>
{navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
{current && (
<div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
}}>
<nav className="quiz-breadcrumbs" aria-label="Question categories">{current.category_breadcrumbs?.length ? current.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && <span aria-hidden="true"> </span>}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
<div className="quiz-stem" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
<h3 id="quiz-question-heading" style={{ marginBottom: 0, flex: 1 }}>
<ManualHighlightText
text={questionStem(current)}
textId={`${current.id}::question`}
highlights={highlightsFor('question')}
speechRange={questionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
</h3>
<button
onClick={() => 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) ? '⭐' : '☆'}
</button>
</div>
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{voices.length > 0 && (
<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 && (
<button
className={`btn btn-sm ${readThrough ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setReadThrough(v => !v)}
title="Read each question aloud and advance automatically"
>
{readThrough ? 'Stop listen-through' : 'Listen through'}
</button>
)}
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
Clear
</button>
</div>
</div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{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] || 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 (
<button type="button" key={i} aria-pressed={isSelected} aria-disabled={hasAnswered}
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !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',
}}>
<span className="option-letter">{letter}</span>
<span className="option-text">
<ManualHighlightText
text={opt}
textId={`${current.id}::${optionFieldKey}`}
highlights={highlightsFor(optionFieldKey)}
speechRange={optionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
{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>}
</button>
)
})}
</div>
) : (
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || draftAnswer}
readOnly={isStudy && Boolean(answers[current.id])}
onChange={e => 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] && <button type="button" className="btn btn-primary quiz-submit-response" disabled={!draftAnswer.trim()} onClick={submitStudyResponse}>Submit response</button>}
{isStudy && answers[current.id] && (
<>
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `${responseStats.sample_size} recorded answers. ${responseStats.basis}` : 'No response statistics available yet.'}</p>}
{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> {current.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>
)}
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}
</div>
)}
</>
)}
</div>
)}
{quizNavigation('bottom')}
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setShowReview(true)} disabled={submitting}>
Review ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
{/* Desktop sidebar */}
<div className="quiz-sidebar">
<div style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: 12 }}>
Questions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
</div>
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginRight: 4 }} />{answeredCount} answered</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--border)', marginRight: 4 }} />{totalCount - answeredCount} remaining</span>
</div>
{answeredCount > 0 && !isLast && (
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
onClick={() => setShowReview(true)} disabled={submitting}>
Review & Complete
</button>
)}
</div>
</div>
</div>
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
<Suspense fallback={null}>
<TeachChat question={current} attemptId={attemptId} />
</Suspense>
)}
</div>
)
}