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.
1327 lines
62 KiB
JavaScript
1327 lines
62 KiB
JavaScript
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 & 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>
|
||
)
|
||
}
|