Add dashboard in-progress quizzes
This commit is contained in:
parent
d4ef94a117
commit
dd959371d1
4 changed files with 64 additions and 50 deletions
50
frontend/src/components/InProgressQuizzes.jsx
Normal file
50
frontend/src/components/InProgressQuizzes.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import api from '../api/client'
|
||||||
|
import ConfirmButton from './ConfirmButton'
|
||||||
|
|
||||||
|
export default function InProgressQuizzes() {
|
||||||
|
const [inProgress, setInProgress] = useState([])
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteAttempt = async (attemptId) => {
|
||||||
|
await api.delete(`/attempts/${attemptId}`)
|
||||||
|
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgress.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
|
||||||
|
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
|
||||||
|
⏸ In Progress ({inProgress.length})
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{inProgress.map(a => (
|
||||||
|
<div key={a.attempt_id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
|
||||||
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||||
|
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
||||||
|
<ConfirmButton
|
||||||
|
label="Delete" confirmLabel="Yes, delete"
|
||||||
|
onConfirm={() => deleteAttempt(a.attempt_id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import LineChart from '../components/LineChart'
|
import LineChart from '../components/LineChart'
|
||||||
|
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
function greeting(name) {
|
function greeting(name) {
|
||||||
|
|
@ -74,6 +75,8 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<InProgressQuizzes />
|
||||||
|
|
||||||
{/* Performance graph with dropdown */}
|
{/* Performance graph with dropdown */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,10 @@ export default function QuizPage() {
|
||||||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adjustImageZoom = (delta) => {
|
||||||
|
setImageZoom(z => Math.max(1, Math.min(4, z + delta)))
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(`quiz-highlights:${id}`)
|
const saved = localStorage.getItem(`quiz-highlights:${id}`)
|
||||||
|
|
@ -1061,10 +1065,12 @@ const timerStarted = timeLeft !== null
|
||||||
{expandedImagePath && (
|
{expandedImagePath && (
|
||||||
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
|
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
|
||||||
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
|
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
|
||||||
<button type="button" onClick={() => setImageZoom(z => Math.max(1, z - 0.25))} disabled={imageZoom <= 1}>-</button>
|
<button type="button" onClick={() => adjustImageZoom(-0.5)} disabled={imageZoom <= 1}>-</button>
|
||||||
<span>{Math.round(imageZoom * 100)}%</span>
|
<span>{Math.round(imageZoom * 100)}%</span>
|
||||||
<button type="button" onClick={() => setImageZoom(z => Math.min(4, z + 0.25))} disabled={imageZoom >= 4}>+</button>
|
<button type="button" onClick={() => adjustImageZoom(0.5)} disabled={imageZoom >= 4}>+</button>
|
||||||
<button type="button" onClick={() => setImageZoom(1)} disabled={imageZoom === 1}>Reset</button>
|
{[1, 2, 3, 4].map(zoom => (
|
||||||
|
<button key={zoom} type="button" onClick={() => setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
||||||
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
|
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
|
||||||
|
|
|
||||||
|
|
@ -4,56 +4,11 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ConfirmButton from '../components/ConfirmButton'
|
import ConfirmButton from '../components/ConfirmButton'
|
||||||
import Dialog from '../components/Dialog'
|
import Dialog from '../components/Dialog'
|
||||||
|
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||||
import { useDialog } from '../hooks/useDialog'
|
import { useDialog } from '../hooks/useDialog'
|
||||||
|
|
||||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||||
|
|
||||||
function InProgressSection() {
|
|
||||||
const [inProgress, setInProgress] = useState([])
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const deleteAttempt = async (attemptId) => {
|
|
||||||
await api.delete(`/attempts/${attemptId}`)
|
|
||||||
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inProgress.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
|
|
||||||
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
|
|
||||||
⏸ In Progress ({inProgress.length})
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{inProgress.map(a => (
|
|
||||||
<div key={a.attempt_id} style={{
|
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
||||||
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
|
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
|
||||||
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
|
|
||||||
<ConfirmButton
|
|
||||||
label="Delete" confirmLabel="Yes, delete"
|
|
||||||
onConfirm={() => deleteAttempt(a.attempt_id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PastAttemptsSection() {
|
function PastAttemptsSection() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [history, setHistory] = useState(null)
|
const [history, setHistory] = useState(null)
|
||||||
|
|
@ -506,7 +461,7 @@ export default function QuizzesPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* In-progress quizzes */}
|
{/* In-progress quizzes */}
|
||||||
{!isSearching && <InProgressSection />}
|
{!isSearching && <InProgressQuizzes />}
|
||||||
|
|
||||||
{/* Past attempts (loads on demand) */}
|
{/* Past attempts (loads on demand) */}
|
||||||
{!isSearching && <PastAttemptsSection />}
|
{!isSearching && <PastAttemptsSection />}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue