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:
parent
b92d2b9232
commit
96f95ed062
4 changed files with 351 additions and 122 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
159
frontend/src/pages/JobsPage.jsx
Normal file
159
frontend/src/pages/JobsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue