- 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>
164 lines
7.2 KiB
JavaScript
164 lines
7.2 KiB
JavaScript
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>
|
||
)
|
||
}
|