Better fonts, hamburger mobile nav, jobs indicator, warm theme refinement

Fonts:
- Added Google Fonts: Inter (UI), Playfair Display (headings), Source Serif 4 (reading)
- Default theme now uses Inter — clean, modern, professional
- Warm Brown theme: body text uses Source Serif 4 (screen-optimized modern serif,
  not old-fashioned Georgia), question text uses Source Serif 4 for readability,
  headings use Playfair Display (elegant, editorial), UI elements use Inter
- Result: literary feel without looking like a Word document

Mobile Navbar (hamburger menu):
- Desktop: horizontal nav unchanged
- Mobile: logo + right-side controls (jobs badge + hamburger icon)
- Hamburger animates to X when open, full-width dropdown below navbar
- Active route highlighted; auto-closes on navigation

Jobs indicator:
- Amber spinner badge in navbar when extractions are running
- Tracks jobs in localStorage (pedquiz_jobs key)
- ExtractionProgress updates localStorage as steps arrive
- Click badge for dropdown showing current step per job

CSS:
- .nav-desktop / .nav-mobile-controls classes for responsive nav
- Default cards: slightly refined shadow and border

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 02:14:18 +02:00
parent 96a1a259f0
commit a5188c4edd
5 changed files with 227 additions and 69 deletions

View file

@ -28,6 +28,8 @@ services:
build: ./backend
env_file:
- ./backend/.env
environment:
- ANONYMIZED_TELEMETRY=False
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
@ -43,6 +45,8 @@ services:
command: celery -A app.tasks worker --loglevel=info --concurrency=2
env_file:
- ./backend/.env
environment:
- ANONYMIZED_TELEMETRY=False
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data

View file

@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PedQuiz</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🩺</text></svg>" />
<!-- Inter: clean professional UI font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@600;700&family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View file

@ -1,25 +1,152 @@
import { Link } from 'react-router-dom'
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 [open, setOpen] = useState(false)
useEffect(() => {
// Load tracked jobs from localStorage
const load = () => {
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
setActiveJobs(stored.filter(j => j.status !== 'completed' && j.status !== 'failed'))
}
load()
const interval = setInterval(load, 3000)
return () => clearInterval(interval)
}, [])
if (activeJobs.length === 0) return null
return (
<div style={{ position: 'relative' }}>
<button onClick={() => setOpen(v => !v)} style={{
background: '#f59e0b', color: 'white', border: 'none', borderRadius: 20,
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 5,
}}>
<div className="spinner" style={{ width: 10, height: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.4)', borderTopColor: 'white' }} />
{activeJobs.length} extracting
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: '120%', background: 'var(--card-bg)',
border: '1px solid var(--border)', borderRadius: 10, padding: 12,
minWidth: 240, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: 200,
}}>
{activeJobs.map(job => (
<div key={job.jobId} style={{ fontSize: '0.8rem', color: 'var(--text)', marginBottom: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{job.title}</div>
<div style={{ color: 'var(--text-muted)' }}>{job.lastStep || 'Processing…'}</div>
</div>
))}
<Link to="/jobs" style={{ fontSize: '0.78rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
onClick={() => setOpen(false)}>
View all extractions
</Link>
</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">
<Link to="/" className="logo">🩺 PedQuiz</Link>
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🩺 PedQuiz</Link>
{user && (
<nav>
<Link to="/">Dashboard</Link>
<Link to="/quizzes">Quizzes</Link>
<Link to="/question-bank">Question Bank</Link>
{isModerator && <Link to="/upload">Upload PDF</Link>}
<Link to="/settings" title="Settings"> Settings</Link>
<button onClick={logout}>Logout</button>
</nav>
<>
{/* 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>
)
}

View file

@ -2,9 +2,9 @@
:root {
--bg: #f0f4f8;
--card-bg: #ffffff;
--card-border: 1px solid rgba(0,0,0,0.05);
--card-shadow: 0 1px 6px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.03);
--card-radius: 14px;
--card-border: 1px solid rgba(0,0,0,0.07);
--card-shadow: 0 1px 4px rgba(0,0,0,0.07), 0 4px 16px rgba(0,0,0,0.04);
--card-radius: 12px;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-fg: #ffffff;
@ -30,68 +30,79 @@
--badge-radius: 12px;
}
/* ── Warm Brown / Parchment theme ────────────────────────────── */
/* ── Warm / Literary theme ────────────────────────────────────── */
[data-theme="markdown"] {
--bg: #f2ebe0;
--card-bg: #faf6ef;
--card-border: 1px solid #d6c9b6;
--card-shadow: 0 1px 3px rgba(80,50,20,0.07);
--card-radius: 8px;
--primary: #7c4a1e;
--primary-hover: #633c18;
--bg: #f5f0e8;
--card-bg: #fdfaf4;
--card-border: 1px solid #e0d5c5;
--card-shadow: 0 2px 8px rgba(60,40,10,0.06);
--card-radius: 10px;
--primary: #8b5a2b;
--primary-hover: #6d4420;
--primary-fg: #fdf8f0;
--text: #2c1a0e;
--text: #1e140a;
--text-muted: #7a5c42;
--text-subtle: #a8886a;
--border: #d6c9b6;
--input-bg: #fdf8f0;
--option-bg: #fdf8f0;
--option-hover: #f5ede0;
--option-sel-bg: #f5e6d3;
--option-sel-bd: #7c4a1e;
--correct-bg: #e6f4e6;
--correct-bd: #8cbf8c;
--correct-fg: #1a4d1a;
--text-subtle: #aa8a6a;
--border: #e0d5c5;
--input-bg: #fdfaf4;
--option-bg: #fdfaf4;
--option-hover: #f7f0e4;
--option-sel-bg: #f2e4d0;
--option-sel-bd: #8b5a2b;
--correct-bg: #e8f5e8;
--correct-bd: #8abf8a;
--correct-fg: #1a4a1a;
--wrong-bg: #fdecea;
--wrong-bd: #d9908a;
--wrong-fg: #6b1e1e;
--expl-bg: #f8f3ea;
--expl-bd: #b8945a;
--navbar-bg: #2c1a0e;
--navbar-fg: #f2e8d8;
--wrong-bd: #d99090;
--wrong-fg: #6b1a1a;
--expl-bg: #f9f4ea;
--expl-bd: #c4965a;
--navbar-bg: #1e140a;
--navbar-fg: #f0e8d8;
--badge-radius: 4px;
--font-body: Georgia, 'Times New Roman', 'Palatino', serif;
--font-heading: Georgia, 'Times New Roman', serif;
--font-body: 'Source Serif 4', Georgia, serif;
--font-heading: 'Playfair Display', 'Source Serif 4', serif;
--font-ui: 'Inter', system-ui, sans-serif;
}
/* ── Warm Brown theme overrides ─────────────────────────────── */
/* ── Warm theme overrides ────────────────────────────────────── */
body[data-theme="markdown"] {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.75;
font-family: var(--font-ui);
font-size: 15.5px;
line-height: 1.7;
}
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
[data-theme="markdown"] h1,
[data-theme="markdown"] h2,
[data-theme="markdown"] h3 {
font-family: var(--font-heading);
font-weight: 700;
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; }
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; }
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; }
[data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; }
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; }
[data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); }
[data-theme="markdown"] .question-card h3 {
font-family: var(--font-body);
font-size: 1.05rem;
line-height: 1.7;
font-weight: 400;
font-style: normal;
color: var(--text);
}
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; }
[data-theme="markdown"] .question-card { border-left: 4px solid var(--primary); }
[data-theme="markdown"] .question-card h3 { font-family: var(--font-heading); font-size: 1.05rem; line-height: 1.65; }
[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.95rem; font-family: inherit; }
[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: Georgia, serif; font-style: italic; }
[data-theme="markdown"] .explanation { font-family: inherit; line-height: 1.85; border-left: 3px solid var(--expl-bd); background: var(--expl-bg); }
[data-theme="markdown"] .btn { font-family: inherit; }
[data-theme="markdown"] .score-display .score-value { letter-spacing: -2px; font-family: Georgia, serif; }
[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.94rem; }
[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: var(--font-ui); font-weight: 600; }
[data-theme="markdown"] .explanation {
font-family: var(--font-body);
font-size: 0.9rem;
line-height: 1.85;
border-left: 3px solid var(--expl-bd);
background: var(--expl-bg);
color: var(--text-muted);
}
[data-theme="markdown"] .btn { font-family: var(--font-ui); font-weight: 500; }
[data-theme="markdown"] .score-display .score-value { font-family: var(--font-heading); letter-spacing: -3px; }
/* ── Reset ──────────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
@ -107,11 +118,11 @@ body {
.navbar .container { display: flex; justify-content: space-between; align-items: center; height: 52px; }
.navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
.navbar nav { display: flex; gap: 4px; align-items: center; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; }
.navbar nav::-webkit-scrollbar { display: none; }
.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.83rem; opacity: 0.7; padding: 6px 10px; border-radius: 6px; white-space: nowrap; transition: opacity 0.15s, background 0.15s; }
.navbar a:hover { opacity: 1; background: rgba(255,255,255,0.08); }
.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; white-space: nowrap; }
.nav-desktop { display: flex; gap: 4px; align-items: center; }
.nav-mobile-controls { display: none; align-items: center; gap: 8px; }
.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.83rem; padding: 6px 10px; border-radius: 6px; white-space: nowrap; transition: opacity 0.15s, background 0.15s; }
.navbar a:hover { background: rgba(255,255,255,0.09); opacity: 1 !important; }
.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; white-space: nowrap; font-family: inherit; }
/* ── Cards ──────────────────────────────────────────────────── */
.card {
@ -305,8 +316,8 @@ body {
.card { padding: 18px 16px; }
.quiz-nav { flex-wrap: wrap; }
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
.navbar .container { height: 48px; }
.navbar .logo { font-size: 1rem; }
.navbar a { font-size: 0.78rem; padding: 5px 7px; }
.navbar button { padding: 4px 10px; font-size: 0.78rem; }
.navbar .container { height: 52px; }
/* Mobile: show hamburger, hide desktop nav */
.nav-desktop { display: none; }
.nav-mobile-controls { display: flex; }
}

View file

@ -14,8 +14,15 @@ function ExtractionProgress({ jobId, onDone, onClose }) {
const poll = useCallback(async () => {
try {
const res = await api.get(`/quizzes/job/${jobId}`)
setSteps(res.data.steps || [])
const newSteps = res.data.steps || []
setSteps(newSteps)
setStatus(res.data.status)
// Update localStorage job tracker
const lastStep = newSteps.length ? newSteps[newSteps.length - 1].message : 'Processing…'
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
const idx = stored.findIndex(j => j.jobId === jobId)
if (idx >= 0) { stored[idx].status = res.data.status; stored[idx].lastStep = lastStep.slice(0, 60) }
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored))
if (res.data.status === 'completed') {
setQuizId(res.data.quiz_id)
clearInterval(intervalRef.current)
@ -179,6 +186,11 @@ export default function DocumentDetailPage() {
})
// Async: show progress panel
if (res.data.job_id) {
// Track in localStorage so navbar badge can show it
const title = quizTitle || `Quiz: ${sectionName}`
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
setActiveJob({ jobId: res.data.job_id, sectionName })
// If already completed (sync fallback), navigate directly
if (res.data.status === 'completed' && res.data.quiz_id) {