pdf-quiz-generator/frontend/src/components/Navbar.jsx
Daniel 871206d891 Cross-browser job tracking, hamburger fix, CLI extract, quiz delete keeps bank questions
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>
2026-04-01 02:30:47 +02:00

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