- Fully decouple course quizzes from main quiz system (hidden from dashboard stats, history, search, results page) - Course quiz results show "Back to Course" instead of retake/delete - Add allow_review toggle for course creators to control answer review - Show quiz title on course page, hide pool size from students - Add course thumbnails to browse cards - Replace passlib with bcrypt directly (compatible with existing hashes) - Add HIBP breached password warnings on register/reset/change password - Add CLI management tools (reset-password, set-role, stats, etc.) - Fix quiz PATCH endpoint: ownership check instead of moderator-only - Add max_length validation on course/module/lesson titles - Fix score display bug on results page (0 of N when review disabled) - Fix question count on course quiz start (show per-attempt, not pool) - Improve suspend warning for timed course quizzes with max attempts - Clean up validation error messages (show "Invalid email" not Pydantic dump) - Add DDL migration for allow_review column Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
11 KiB
JavaScript
223 lines
11 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
|
|
import api from '../api/client'
|
|
|
|
export default function ResultsPage() {
|
|
const { id } = useParams()
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const [searchParams] = useSearchParams()
|
|
const returnTo = searchParams.get('return_to')
|
|
const [result, setResult] = useState(location.state?.result || null)
|
|
const [loading, setLoading] = useState(!result)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
|
|
const isCourseQuiz = result?.course_id != null
|
|
const reviewAllowed = result?.allow_review !== false
|
|
|
|
const deleteAttempt = async () => {
|
|
if (!confirmDelete) { setConfirmDelete(true); return }
|
|
setDeleting(true)
|
|
try {
|
|
await api.delete(`/attempts/${result.id}`)
|
|
navigate(returnTo || '/quizzes')
|
|
} catch {
|
|
setDeleting(false)
|
|
setConfirmDelete(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!result) {
|
|
api.get(`/attempts/${id}`)
|
|
.then(res => setResult(res.data))
|
|
.catch(() => navigate(returnTo || '/quizzes'))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
}, [id])
|
|
|
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
|
|
if (!result) return null
|
|
|
|
const pct = result.percentage
|
|
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
|
|
const correct = result.answers?.length > 0 ? result.answers.filter(a => a.is_correct).length : result.score
|
|
const total = result.total_questions
|
|
|
|
return (
|
|
<div style={{ maxWidth: 780, margin: '0 auto' }}>
|
|
{/* Score card */}
|
|
<div className="card" style={{ marginBottom: 24 }}>
|
|
<div className="score-display">
|
|
<div className={`score-value ${scoreClass}`}>{pct}%</div>
|
|
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
|
|
{correct} of {total} correct
|
|
</div>
|
|
<div style={{ marginTop: 10, fontSize: '0.95rem', color: 'var(--text-muted)' }}>
|
|
{pct >= 90 ? "Excellent — you've mastered this material." :
|
|
pct >= 75 ? 'Good work! Review any missed questions below.' :
|
|
pct >= 50 ? 'Getting there — study the explanations and retake.' :
|
|
'Review all explanations carefully before retaking.'}
|
|
</div>
|
|
|
|
{/* Score bar */}
|
|
<div style={{ maxWidth: 320, margin: '20px auto 24px' }}>
|
|
<div style={{ height: 8, background: 'var(--border)', borderRadius: 999, overflow: 'hidden' }}>
|
|
<div style={{
|
|
height: '100%', borderRadius: 999, transition: 'width 0.6s ease',
|
|
width: `${pct}%`,
|
|
background: pct >= 75 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444',
|
|
}} />
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4, fontSize: '0.75rem', color: 'var(--text-subtle)' }}>
|
|
<span>0%</span><span style={{ color: '#d97706' }}>75%</span><span>100%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
|
{isCourseQuiz ? (
|
|
<>
|
|
{returnTo && (
|
|
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
|
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
|
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
|
{confirmDelete ? (
|
|
<>
|
|
<button
|
|
onClick={deleteAttempt}
|
|
disabled={deleting}
|
|
className="btn btn-secondary"
|
|
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
|
>
|
|
{deleting ? 'Deleting…' : 'Confirm Delete'}
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={deleteAttempt}
|
|
className="btn btn-secondary"
|
|
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
|
>
|
|
Delete Attempt
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Question review — only if allowed */}
|
|
{reviewAllowed && result.answers && result.answers.length > 0 && (
|
|
<>
|
|
{/* Summary row */}
|
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
|
{[
|
|
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
|
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
|
|
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
|
|
].map(s => (
|
|
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
|
|
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
|
|
|
{result.answers.map((ans, idx) => {
|
|
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
|
return (
|
|
<div className={`review-card ${cardClass}`} key={idx}>
|
|
{/* Question header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
Question {idx + 1}
|
|
</span>
|
|
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
|
|
{ans.question_text}
|
|
</p>
|
|
</div>
|
|
<span style={{
|
|
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
|
|
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
|
|
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
|
|
}}>
|
|
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Options with letter badges */}
|
|
{ans.options && ans.options.length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
|
{ans.options.map((opt, i) => {
|
|
const isCorrect = opt === ans.correct_answer
|
|
const isUser = opt === ans.user_answer
|
|
const isWrong = isUser && !isCorrect
|
|
const letter = String.fromCharCode(65 + i)
|
|
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
|
|
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
|
|
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
|
|
return (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
|
fontSize: '0.73rem', fontWeight: 700,
|
|
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
|
|
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
|
|
}}>{letter}</span>
|
|
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
|
|
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Correct answer</span>}
|
|
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Your answer</span>}
|
|
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✗ Your answer</span>}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Fill-blank */}
|
|
{(!ans.options || ans.options.length === 0) && (
|
|
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
|
|
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
|
|
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
|
|
</div>
|
|
{!ans.is_correct && (
|
|
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
|
|
<strong>Correct answer:</strong> {ans.correct_answer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Explanation */}
|
|
{ans.explanation && (
|
|
<div className="explanation">
|
|
<strong>Explanation</strong>
|
|
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{!reviewAllowed && (
|
|
<div className="card" style={{ textAlign: 'center' }}>
|
|
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|