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:
parent
96a1a259f0
commit
a5188c4edd
5 changed files with 227 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue