Jobs (cross-browser/cross-session):
- POST /quizzes/ stores job_id in Redis under user key (extraction:user_jobs:{uid})
- GET /quizzes/jobs returns all recent jobs for current user from any browser/session
- Navbar JobsBadge polls /quizzes/jobs API every 4s (not localStorage)
- Shows all recent jobs with status badges; links to quiz when complete
- Badge visible even after extraction completes so you can always get back
Mobile navbar fix:
- .navbar .container height was overriding dropdown to 52px (clipping all links)
- Fixed by using .navbar-inner class for the header row only
CLI extract command:
python manage.py list-sections [doc_id] — list docs + sections with IDs
python manage.py extract <section_id> — inline blocking extraction
python manage.py extract <section_id> --bg — background via Celery
python manage.py jobs — show all extraction jobs in Redis
python manage.py jobs --user <email> — filter by user
Quiz delete + question bank:
- When a quiz is deleted, questions that belong ONLY to that quiz are deleted
- Questions shared with other quizzes (via junction) are kept in the bank
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
170 lines
7.3 KiB
JavaScript
170 lines
7.3 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Link, useLocation } from 'react-router-dom'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import api from '../api/client'
|
|
|
|
function JobsBadge() {
|
|
const [activeJobs, setActiveJobs] = useState([])
|
|
const [allJobs, setAllJobs] = useState([])
|
|
const [open, setOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const res = await api.get('/quizzes/jobs')
|
|
setAllJobs(res.data)
|
|
setActiveJobs(res.data.filter(j => j.status !== 'completed' && j.status !== 'failed'))
|
|
} catch { }
|
|
}
|
|
load()
|
|
const interval = setInterval(load, 4000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
if (allJobs.length === 0) return null
|
|
|
|
const running = activeJobs.length
|
|
return (
|
|
<div style={{ position: 'relative' }}>
|
|
<button onClick={() => setOpen(v => !v)} style={{
|
|
background: running > 0 ? '#f59e0b' : 'rgba(255,255,255,0.12)',
|
|
color: 'white', border: 'none', borderRadius: 20,
|
|
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', gap: 5,
|
|
}}>
|
|
{running > 0 && <div className="spinner" style={{ width: 10, height: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.4)', borderTopColor: 'white' }} />}
|
|
{running > 0 ? `${running} extracting` : `${allJobs.length} jobs`}
|
|
</button>
|
|
{open && (
|
|
<div style={{
|
|
position: 'absolute', right: 0, top: '120%', background: 'var(--card-bg)',
|
|
border: '1px solid var(--border)', borderRadius: 10, padding: 12,
|
|
minWidth: 260, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 200,
|
|
}}>
|
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
|
|
Extractions
|
|
</div>
|
|
{allJobs.slice(0, 5).map(job => (
|
|
<div key={job.job_id} style={{ fontSize: '0.82rem', color: 'var(--text)', marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 3 }}>
|
|
<span style={{ fontWeight: 600 }}>{job.title}</span>
|
|
<span style={{
|
|
fontSize: '0.7rem', fontWeight: 600, padding: '1px 6px', borderRadius: 10,
|
|
background: job.status === 'completed' ? 'var(--correct-bg)' : job.status === 'failed' ? 'var(--wrong-bg)' : '#fef3c7',
|
|
color: job.status === 'completed' ? 'var(--correct-fg)' : job.status === 'failed' ? 'var(--wrong-fg)' : '#92400e',
|
|
}}>
|
|
{job.status === 'running' ? `${job.steps_count} steps` : job.status}
|
|
</span>
|
|
</div>
|
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
|
|
{job.status === 'completed' && job.quiz_id && (
|
|
<Link to={`/quizzes/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
|
|
onClick={() => setOpen(false)}>Open Quiz →</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
{allJobs.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.82rem' }}>No recent extractions</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Navbar() {
|
|
const { user, logout } = useAuth()
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const location = useLocation()
|
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
|
|
|
// Close menu on route change
|
|
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
|
|
|
const navLinks = user ? [
|
|
{ to: '/', label: 'Dashboard' },
|
|
{ to: '/quizzes', label: 'Quizzes' },
|
|
{ to: '/question-bank', label: 'Question Bank' },
|
|
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
|
|
{ to: '/settings', label: '⚙ Settings' },
|
|
] : []
|
|
|
|
return (
|
|
<div className="navbar">
|
|
<div className="container navbar-inner">
|
|
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🩺 PedQuiz</Link>
|
|
|
|
{user && (
|
|
<>
|
|
{/* Desktop nav */}
|
|
<nav className="nav-desktop">
|
|
{navLinks.map(l => (
|
|
<Link key={l.to} to={l.to}
|
|
style={{ fontWeight: location.pathname === l.to ? 600 : 400, opacity: location.pathname === l.to ? 1 : 0.7 }}>
|
|
{l.label}
|
|
</Link>
|
|
))}
|
|
<JobsBadge />
|
|
<button onClick={logout}>Logout</button>
|
|
</nav>
|
|
|
|
{/* Mobile: jobs + hamburger */}
|
|
<div className="nav-mobile-controls">
|
|
<JobsBadge />
|
|
<button
|
|
onClick={() => setMenuOpen(v => !v)}
|
|
aria-label="Menu"
|
|
style={{
|
|
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--navbar-fg)',
|
|
padding: '6px', display: 'flex', flexDirection: 'column', gap: 5, opacity: 0.85,
|
|
}}
|
|
>
|
|
<span style={{
|
|
display: 'block', width: 22, height: 2, background: 'currentColor',
|
|
borderRadius: 2, transition: 'transform 0.2s, opacity 0.2s',
|
|
transform: menuOpen ? 'translateY(7px) rotate(45deg)' : 'none',
|
|
}} />
|
|
<span style={{
|
|
display: 'block', width: 22, height: 2, background: 'currentColor',
|
|
borderRadius: 2, opacity: menuOpen ? 0 : 1, transition: 'opacity 0.2s',
|
|
}} />
|
|
<span style={{
|
|
display: 'block', width: 22, height: 2, background: 'currentColor',
|
|
borderRadius: 2, transition: 'transform 0.2s, opacity 0.2s',
|
|
transform: menuOpen ? 'translateY(-7px) rotate(-45deg)' : 'none',
|
|
}} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile dropdown */}
|
|
{user && menuOpen && (
|
|
<div style={{
|
|
background: 'var(--navbar-bg)', borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
padding: '8px 0 12px',
|
|
}}>
|
|
<div className="container" style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{navLinks.map(l => (
|
|
<Link key={l.to} to={l.to} style={{
|
|
color: 'var(--navbar-fg)', textDecoration: 'none',
|
|
padding: '10px 4px', fontSize: '0.95rem',
|
|
fontWeight: location.pathname === l.to ? 600 : 400,
|
|
opacity: location.pathname === l.to ? 1 : 0.75,
|
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
|
}}>
|
|
{l.label}
|
|
</Link>
|
|
))}
|
|
<button onClick={() => { setMenuOpen(false); logout() }} style={{
|
|
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
|
|
color: 'var(--navbar-fg)', padding: '10px', borderRadius: 8,
|
|
cursor: 'pointer', fontSize: '0.9rem', marginTop: 6, width: '100%',
|
|
}}>
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|