UWorld-style quiz sidebar, mobile nav toggle, jobs page with skipped details

Quiz layout:
- Desktop: sticky right sidebar (200px) showing all question numbers with
  answered/current status — like UWorld. Main content fills the rest.
- Mobile: sidebar hidden; a "N / Total ▲" toggle button between Prev/Next
  reveals a collapsible grid of question numbers below the nav buttons
- Question dots: circle buttons colored blue=current, green=answered, gray=unanswered
- Both sidebar and mobile grid close/update on selection

Jobs page (/jobs — moderator):
- List of all extraction jobs (from Redis, same session or different browser)
- Each job shows: status badge, questions extracted, skipped count
- "Details" expands to show:
  - Skipped questions warning: names + explanation of why (no correct answer found)
  - Chunk breakdown: per-50-page results
  - Full extraction log (collapsible <details>)
- Live polling for running jobs
- "Open Quiz" button links directly to the quiz

Navigation:
- Jobs page linked from NavBar jobs badge dropdown
- Route: /jobs (moderator required)
- App.jsx updated with new route

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 02:48:59 +02:00
parent b92d2b9232
commit 96f95ed062
4 changed files with 351 additions and 122 deletions

View file

@ -14,6 +14,7 @@ import AdminPage from './pages/AdminPage'
import AccountPage from './pages/AccountPage'
import SettingsPage from './pages/SettingsPage'
import QuestionBankPage from './pages/QuestionBankPage'
import JobsPage from './pages/JobsPage'
import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
@ -62,6 +63,7 @@ function AppRoutes() {
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
</Routes>
</div>
<Footer />

View file

@ -294,22 +294,44 @@ body {
font-size: 0.8rem;
}
/* ── Quiz page bottom spacing ───────────────────────────────── */
/* ── Quiz page layout ───────────────────────────────────────── */
.quiz-bottom { padding-bottom: 48px; }
.quiz-nav {
/* Two-column: content + sidebar */
.quiz-layout {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
gap: 8px;
gap: 24px;
align-items: flex-start;
}
.quiz-nav-numbers {
/* Desktop sidebar (UWorld-style) */
.quiz-sidebar {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 68px;
background: var(--card-bg);
border: var(--card-border);
border-radius: var(--card-radius);
padding: 16px;
box-shadow: var(--card-shadow);
max-height: calc(100vh - 100px);
overflow-y: auto;
}
/* Mobile: hide sidebar, show toggle button */
.quiz-nav-toggle { display: none; }
.quiz-nav-mobile-grid {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
flex: 1;
min-width: 0;
gap: 6px;
padding: 14px;
margin-top: 10px;
background: var(--card-bg);
border: var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
animation: fadeInUp 0.15s ease;
}
/* ── Responsive ─────────────────────────────────────────────── */
@ -319,10 +341,11 @@ body {
.stats-grid { grid-template-columns: 1fr 1fr; }
.question-card, .review-card { padding: 18px 14px; }
.card { padding: 18px 16px; }
.quiz-nav { flex-wrap: wrap; }
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
.navbar .navbar-inner { height: 48px; }
/* Mobile: show hamburger, hide desktop nav */
.nav-desktop { display: none; }
.nav-mobile-controls { display: flex; }
/* Mobile quiz: hide sidebar, show toggle */
.quiz-sidebar { display: none; }
.quiz-nav-toggle { display: inline-flex; }
}

View file

@ -0,0 +1,159 @@
import { useState, useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
function JobDetail({ job }) {
const [steps, setSteps] = useState([])
const [expanded, setExpanded] = useState(false)
const [quizData, setQuizData] = useState(null)
const pollRef = useRef(null)
const navigate = useNavigate()
const load = async () => {
try {
const res = await api.get(`/quizzes/job/${job.job_id}`)
setSteps(res.data.steps || [])
if (res.data.status === 'completed' && res.data.quiz_id && !quizData) {
const qRes = await api.get(`/quizzes/${res.data.quiz_id}`)
setQuizData(qRes.data)
}
} catch { }
}
useEffect(() => {
if (expanded) {
load()
if (job.status === 'running') {
pollRef.current = setInterval(load, 3000)
}
}
return () => clearInterval(pollRef.current)
}, [expanded, job.status])
// Parse skipped questions from quiz data
const skipped = quizData?.skipped_questions ? JSON.parse(quizData.skipped_questions) : []
// Parse chunk summaries from steps
const chunkSteps = steps.filter(s => s.step === 'ai' && s.message.includes('questions extracted'))
const statusColor = job.status === 'completed' ? 'var(--correct-fg)' : job.status === 'failed' ? 'var(--wrong-fg)' : '#92400e'
const statusBg = job.status === 'completed' ? 'var(--correct-bg)' : job.status === 'failed' ? 'var(--wrong-bg)' : '#fef3c7'
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 4, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: '0.95rem' }}>{job.title}</span>
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: statusBg, color: statusColor }}>
{job.status}
</span>
{job.status === 'running' && <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{job.steps_count} steps
{quizData && ` · ${quizData.questions_count} questions extracted`}
{skipped.length > 0 && <span style={{ color: 'var(--wrong-fg)', marginLeft: 8 }}>· {skipped.length} questions skipped</span>}
</div>
{job.last_step && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 2 }}>{job.last_step}</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{job.quiz_id && (
<Link to={`/quizzes/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setExpanded(v => !v)}>
{expanded ? 'Hide' : 'Details'}
</button>
</div>
</div>
{expanded && (
<div style={{ marginTop: 16 }}>
{/* Skipped questions warning */}
{skipped.length > 0 && (
<div style={{ background: 'var(--wrong-bg)', border: '1px solid var(--wrong-bd)', borderRadius: 8, padding: '12px 14px', marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--wrong-fg)', marginBottom: 8 }}>
{skipped.length} question{skipped.length !== 1 ? 's' : ''} could not be imported no correct answer found
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--wrong-fg)', marginBottom: 8, opacity: 0.85 }}>
These questions were extracted from the PDF but skipped because the AI could not identify the correct answer. This usually means the "Correct Answer:" line was missing or ambiguous in the source document.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{skipped.map((q, i) => (
<div key={i} style={{ fontSize: '0.78rem', color: 'var(--text-muted)', padding: '4px 8px', background: 'rgba(255,255,255,0.5)', borderRadius: 4 }}>
{i + 1}. {q}
</div>
))}
</div>
</div>
)}
{/* Per-chunk results */}
{chunkSteps.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
Chunk Breakdown
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{chunkSteps.map((s, i) => (
<div key={i} style={{ fontSize: '0.8rem', color: 'var(--text)', padding: '4px 10px', background: 'var(--bg)', borderRadius: 6 }}>
{s.message}
</div>
))}
</div>
</div>
)}
{/* Full step log */}
{steps.length > 0 && (
<details>
<summary style={{ fontSize: '0.78rem', color: 'var(--text-muted)', cursor: 'pointer', marginBottom: 8 }}>
Full extraction log ({steps.length} steps)
</summary>
<div style={{ background: 'var(--bg)', borderRadius: 8, padding: '10px 14px', fontFamily: 'monospace', fontSize: '0.75rem', maxHeight: 260, overflowY: 'auto' }}>
{steps.map((s, i) => (
<div key={i} style={{ marginBottom: 5, color: s.step === 'error' ? 'var(--wrong-fg)' : s.step === 'done' ? 'var(--correct-fg)' : 'var(--text-muted)' }}>
<span style={{ opacity: 0.5, marginRight: 8 }}>[{s.step}]</span>{s.message}
</div>
))}
</div>
</details>
)}
</div>
)}
</div>
)
}
export default function JobsPage() {
const [jobs, setJobs] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/quizzes/jobs')
.then(res => setJobs(res.data))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading"><div className="spinner" /></div>
return (
<div style={{ maxWidth: 760, margin: '0 auto' }}>
<div className="card" style={{ marginBottom: 16 }}>
<h2>Extraction Jobs</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginTop: 4 }}>
History of quiz extractions including skipped questions and chunk details.
</p>
</div>
{jobs.length === 0 ? (
<div className="card"><div className="empty-state">No extractions yet.</div></div>
) : (
jobs.map(job => <JobDetail key={job.job_id} job={job} />)
)}
</div>
)
}

View file

@ -136,6 +136,7 @@ export default function QuizPage() {
const [timeLeft, setTimeLeft] = useState(null)
const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false)
const timerRef = useRef(null)
const toastRef = useRef(null)
const hasStarted = useRef(false)
@ -237,6 +238,20 @@ useEffect(() => {
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
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">
{toast && (
@ -250,145 +265,175 @@ useEffect(() => {
{toast}
</div>
)}
<div className="card" style={{ marginBottom: 16 }}>
{/* Header */}
<div className="card" style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{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: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
}}>
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
{isStudy ? '📖 Study' : '🎯 Exam'}
</span>
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
<span>Q {currentIdx + 1} / {totalCount}</span>
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
</div>
</div>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div>
</div>
{voices.length > 1 && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ fontSize: '0.78rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊 Voice:</label>
<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}
title={ttsActive ? 'Stop audio before changing voice' : ''}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.8rem', opacity: ttsActive ? 0.5 : 1 }}>
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: 20 }}>
<div className="progress-bar" style={{ marginBottom: 16 }}>
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{current && (
<div className="question-card">
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
{voices.length > 0 && (
<div style={{ marginBottom: 12 }}>
<TTSButton
key={`${current.id}_${answers[current.id] || ''}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
voice={selectedVoice}
onActiveChange={setTtsActive}
/>
</div>
)}
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{current.image_path && (
<div style={{ margin: '12px 0' }}>
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 12 }}>
{current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt
const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt === current.correct_answer
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}
>
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
) : (
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || ''}
onChange={e => setAnswer(current.id, e.target.value)}
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }} />
)}
{/* Study mode: show explanation immediately after answering */}
{isStudy && answers[current.id] && (
<>
{current.explanation && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
<strong>Explanation:</strong> {current.explanation}
{/* Two-column layout: content + desktop sidebar */}
<div className="quiz-layout">
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{current && (
<div className="question-card">
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
{voices.length > 0 && (
<div style={{ marginBottom: 10 }}>
<TTSButton
key={`${current.id}_${answers[current.id] || ''}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
voice={selectedVoice}
onActiveChange={setTtsActive}
/>
</div>
)}
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}
<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 && (
<div style={{ margin: '10px 0' }}>
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
</>
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 8 }}>
{current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt
const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt === current.correct_answer
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
) : (
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || ''}
onChange={e => setAnswer(current.id, e.target.value)}
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] && (
<>
{current.explanation && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
<strong>Explanation:</strong> {current.explanation}
</div>
)}
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}
</div>
)}
</>
)}
</div>
)}
{/* Prev / Next + mobile nav toggle */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}> Prev</button>
{/* Mobile-only nav toggle */}
<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={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}>Next </button>
)}
</div>
{/* Mobile: collapsible number grid */}
{navOpen && (
<div className="quiz-nav-mobile-grid">
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
</div>
)}
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<button className="btn btn-secondary btn-sm" onClick={() => handleSubmit(false)} disabled={submitting}>
Submit now ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
)}
<div className="quiz-nav">
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}
> Previous</button>
<div className="quiz-nav-numbers">
{questions.map((q, i) => (
<button key={q.id} onClick={() => safeNavigate(i)} style={{
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
}}>{i + 1}</button>
))}
{/* 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={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
)}
</div>
</div>
{isLast ? (
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}>Next </button>
)}
</div>
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button className="btn btn-secondary" onClick={() => handleSubmit(false)} disabled={submitting}>
Submit now ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
)
}