pdf-quiz-generator/frontend/src/pages/ResultsPage.jsx
Daniel 8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- 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>
2026-04-06 21:31:05 +02:00

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>
)
}