pdf-quiz-generator/frontend/src/pages/DashboardPage.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

164 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import LineChart from '../components/LineChart'
import { useAuth } from '../context/AuthContext'
function greeting(name) {
const h = new Date().getHours()
const time = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'
const first = name?.split(' ')[0] || ''
return `${time}${first ? `, ${first}` : ''}`
}
export default function DashboardPage() {
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [stats, setStats] = useState(null)
const [history, setHistory] = useState([])
const [selectedQuizId, setSelectedQuizId] = useState(null)
const [loading, setLoading] = useState(true)
const [deletingAttempt, setDeletingAttempt] = useState(null)
const [confirmAttempt, setConfirmAttempt] = useState(null)
const deleteAttempt = async (attemptId) => {
if (confirmAttempt !== attemptId) { setConfirmAttempt(attemptId); return }
setDeletingAttempt(attemptId)
setConfirmAttempt(null)
try {
await api.delete(`/attempts/${attemptId}`)
setHistory(prev => prev.map(q => ({
...q,
attempts: q.attempts.filter(a => a.attempt_id !== attemptId),
})).filter(q => q.attempts.length > 0))
} catch { }
finally { setDeletingAttempt(null) }
}
useEffect(() => {
const promises = [
api.get('/attempts/stats/dashboard'),
api.get('/attempts/history'),
]
Promise.all(promises).then(([statsRes, histRes]) => {
setStats(statsRes.data)
setHistory(histRes.data)
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
}).catch(console.error)
.finally(() => setLoading(false))
}, [isModerator])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
const greetingText = greeting(user?.name)
const selectedQuiz = history.find(q => q.quiz_id === selectedQuizId)
return (
<div>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
</div>
{stats && (
<div className="stats-grid">
{[
{ value: stats.total_quizzes, label: 'Quizzes' },
{ value: stats.total_attempts, label: 'Attempts' },
{ value: `${stats.average_score}%`, label: 'Avg Score' },
].map(s => (
<div className="stat-card" key={s.label}>
<div className="stat-value">{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
)}
{/* Performance graph with dropdown */}
{history.length > 0 && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ margin: 0 }}>Performance</h2>
<select
value={selectedQuizId || ''}
onChange={e => setSelectedQuizId(Number(e.target.value))}
style={{ padding: '6px 12px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', maxWidth: 280 }}
>
{history.map(q => (
<option key={q.quiz_id} value={q.quiz_id}>{q.title}</option>
))}
</select>
</div>
{selectedQuiz && (
<>
<div style={{ display: 'flex', gap: 16, marginBottom: 12, fontSize: '0.85rem', color: '#64748b' }}>
<span>{selectedQuiz.attempts.length} attempt{selectedQuiz.attempts.length !== 1 ? 's' : ''}</span>
{selectedQuiz.attempts.length > 0 && (
<>
<span>Latest: <strong style={{
color: selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage >= 75 ? '#22c55e' : '#ef4444'
}}>{selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage}%</strong></span>
<span>Best: <strong>{Math.max(...selectedQuiz.attempts.map(a => a.percentage))}%</strong></span>
</>
)}
<Link to={`/quizzes/${selectedQuiz.quiz_id}`} className="btn btn-primary btn-sm" style={{ marginLeft: 'auto' }}>Retake</Link>
</div>
<LineChart data={selectedQuiz.attempts} />
{selectedQuiz.attempts.length > 0 && selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage < 75 && (
<p style={{ margin: '8px 0 0', fontSize: '0.8rem', color: '#d97706' }}>
Below 75% a reminder will be sent to review this quiz
</p>
)}
{/* Attempt rows with delete */}
{selectedQuiz.attempts.length > 0 && (
<div style={{ marginTop: 14, borderTop: '1px solid var(--border)', paddingTop: 10 }}>
{[...selectedQuiz.attempts].reverse().map(a => (
<div key={a.attempt_id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '5px 0', borderBottom: '1px solid var(--border)',
fontSize: '0.82rem', color: 'var(--text-muted)',
}}>
<span style={{ flex: 1 }}>{new Date(a.date).toLocaleDateString()}</span>
<Link to={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', textDecoration: 'none', fontWeight: 600 }}>
{a.percentage}% ({a.score}/{a.total})
</Link>
{confirmAttempt === a.attempt_id ? (
<>
<button
onClick={() => deleteAttempt(a.attempt_id)}
disabled={deletingAttempt === a.attempt_id}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--wrong-fg)', fontSize: '0.75rem', padding: '2px 4px', fontWeight: 700 }}
>
{deletingAttempt === a.attempt_id ? '…' : 'Delete'}
</button>
<button
onClick={() => setConfirmAttempt(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.75rem', padding: '2px 4px' }}
>Cancel</button>
</>
) : (
<button
onClick={() => deleteAttempt(a.attempt_id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--wrong-fg)', fontSize: '0.78rem', padding: '2px 6px', borderRadius: 4, opacity: 0.6 }}
title="Delete this attempt"
></button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
)}
{isModerator && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
</div>
)}
</div>
)
}