pdf-quiz-generator/frontend/src/pages/QuizzesPage.jsx
Daniel 12d99d3609 Add switchable embedding model, Polly toggle, job cancellation, and UI fixes
Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)

Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled

Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions

Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count

README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:44:11 +02:00

446 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
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 HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <span>{text}</span>
return (
<span>
{text.slice(0, idx)}
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</span>
)
}
function QuestionStudyModal({ question, query, onClose }) {
const [answered, setAnswered] = useState(null)
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Study Question</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>
<HighlightText text={question.question_text} query={query} />
</p>
{question.options && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{question.options.map((opt, i) => {
const isSelected = answered === opt
const hasAnswered = !!answered
const isCorrectOpt = opt === question.correct_answer
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswered(opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}><HighlightText text={opt} query={query} /></span>
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
)}
{answered && question.explanation && (
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
)}
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>
)}
</div>
</div>
)
}
function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange }) {
const navigate = useNavigate()
const [showCatMenu, setShowCatMenu] = useState(false)
const assignCategory = async (catId) => {
try {
await api.patch(`/categories/quizzes/${quiz.id}`, null, { params: { category_id: catId ?? '' } })
onCategoryChange(quiz.id, catId)
} catch { }
setShowCatMenu(false)
}
const togglePublish = async (e) => {
e.stopPropagation()
try {
const newVal = quiz.is_published === 0
await api.patch(`/quizzes/${quiz.id}/publish`, null, { params: { published: newVal } })
onCategoryChange(quiz.id, quiz.category_id, newVal ? 1 : 0) // reuse callback to update state
} catch { }
}
return (
<div onClick={() => navigate(`/quizzes/${quiz.id}`)}
style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 'var(--card-radius)', padding: '20px 18px', cursor: 'pointer', transition: 'box-shadow 0.15s, transform 0.15s', display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', boxShadow: 'var(--card-shadow)' }}
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}>
{isModerator && (
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
<Link to={`/quizzes/${quiz.id}/edit`} title="Edit quiz"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4, textDecoration: 'none', display: 'flex', alignItems: 'center' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></Link>
<button title={quiz.is_published === 0 ? 'Hidden from users — click to publish' : 'Visible — click to hide'} onClick={togglePublish}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: quiz.is_published === 0 ? '#ef4444' : '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
{quiz.is_published === 0 ? '🙈' : '👁'}
</button>
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
<button title="Move to category" onClick={() => setShowCatMenu(v => !v)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>🏷</button>
{showCatMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 4, zIndex: 100, minWidth: 160, boxShadow: '0 4px 16px rgba(0,0,0,0.15)' }}>
<div onClick={() => assignCategory(null)} style={{ padding: '7px 12px', fontSize: '0.82rem', cursor: 'pointer', color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg)'}
onMouseLeave={e => e.currentTarget.style.background = ''}>Uncategorized</div>
{categories.map(cat => (
<div key={cat.id} onClick={() => assignCategory(cat.id)} style={{ padding: '7px 12px', fontSize: '0.82rem', cursor: 'pointer', fontWeight: quiz.category_id === cat.id ? 700 : 400, color: 'var(--text)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg)'}
onMouseLeave={e => e.currentTarget.style.background = ''}>{quiz.category_id === cat.id ? '✓ ' : ''}{cat.name}</div>
))}
</div>
)}
</div>
<ConfirmButton
label="✕" confirmLabel="Move to Trash?" cancelLabel="✕"
className="btn-sm" confirmClassName="btn btn-danger btn-sm"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 6px' }}
onConfirm={() => onDelete(quiz.id)}
/>
</div>
)}
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
{quiz.mode === 'learning' ? '📖' : '📝'}
</div>
<div style={{ paddingRight: isModerator ? 48 : 0 }}>
<div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', lineHeight: 1.3 }}>{quiz.title}</div>
</div>
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>{quiz.questions_count} question{quiz.questions_count !== 1 ? 's' : ''}</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: 20, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', color: quiz.mode === 'learning' ? '#065f46' : '#3730a3' }}>
{quiz.mode === 'learning' ? 'Learning' : 'Timed'}
</span>
{quiz.time_limit_minutes && <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{quiz.time_limit_minutes} min</span>}
</div>
</div>
</div>
)
}
export default function QuizzesPage() {
const [quizzes, setQuizzes] = useState([])
const [categories, setCategories] = useState([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('all')
const [searchResults, setSearchResults] = useState(null)
const [searching, setSearching] = useState(false)
const [expandedSearch, setExpandedSearch] = useState(false)
const [studyQuestion, setStudyQuestion] = useState(null)
const [newCatName, setNewCatName] = useState('')
const [addingCat, setAddingCat] = useState(false)
const debounceRef = useRef(null)
const { user } = useAuth()
const navigate = useNavigate()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
useEffect(() => {
Promise.all([
api.get('/quizzes/'),
api.get('/categories/'),
]).then(([qRes, cRes]) => {
setQuizzes(qRes.data)
setCategories(cRes.data)
}).catch(console.error).finally(() => setLoading(false))
}, [])
useEffect(() => {
clearTimeout(debounceRef.current)
if (searchQuery.trim().length < 2) { setSearchResults(null); return }
setSearching(true)
debounceRef.current = setTimeout(async () => {
try {
const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
setSearchResults(res.data)
} catch { setSearchResults([]) }
finally { setSearching(false) }
}, 350)
return () => clearTimeout(debounceRef.current)
}, [searchQuery, searchMode])
const deleteQuiz = async (quizId) => {
try {
await api.delete(`/quizzes/${quizId}`)
setQuizzes(prev => prev.filter(q => q.id !== quizId))
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
} catch (err) { console.error(err) }
}
const handleCategoryChange = (quizId, catId, isPublished) => {
setQuizzes(prev => prev.map(q => q.id === quizId ? {
...q,
category_id: catId !== undefined ? catId : q.category_id,
is_published: isPublished !== undefined ? isPublished : q.is_published,
} : q))
}
const addCategory = async () => {
if (!newCatName.trim()) return
try {
const res = await api.post('/categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data])
setNewCatName('')
setAddingCat(false)
} catch (err) { alert(err.response?.data?.detail || 'Failed to create category') }
}
const deleteCategory = async (catId) => {
try {
await api.delete(`/categories/${catId}`)
setCategories(prev => prev.filter(c => c.id !== catId))
setQuizzes(prev => prev.map(q => q.category_id === catId ? { ...q, category_id: null } : q))
} catch { }
}
const isSearching = searchQuery.trim().length >= 2
const allSearchQuestions = searchResults?.flatMap(r => r.matching_questions.map(q => ({ ...q, quiz_title: r.quiz_title }))) ?? []
if (loading) return <div className="loading"><div className="spinner" /> Loading...</div>
// Group quizzes by category
const catMap = {}
for (const cat of categories) catMap[cat.id] = { ...cat, quizzes: [] }
const uncategorized = []
for (const quiz of quizzes) {
if (quiz.category_id && catMap[quiz.category_id]) catMap[quiz.category_id].quizzes.push(quiz)
else uncategorized.push(quiz)
}
return (
<div>
{studyQuestion && (
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
)}
{/* Search bar */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span>
<input type="text" placeholder="Search quizzes or questions..."
value={searchQuery} onChange={e => { setSearchQuery(e.target.value); setExpandedSearch(false) }}
style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
{[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
<button key={val} onClick={() => setSearchMode(val)} className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}>{label}</button>
))}
</div>
</div>
{isSearching && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 12, alignItems: 'center' }}>
<span>{searching ? 'Searching...' : searchResults ? `${searchResults.length} quiz result${searchResults.length !== 1 ? 's' : ''}, ${allSearchQuestions.length} matching questions` : ''}</span>
{!searching && allSearchQuestions.length > 0 && (
<button className="btn btn-sm btn-secondary" onClick={() => setExpandedSearch(v => !v)}>
{expandedSearch ? 'Show summary' : `View all ${allSearchQuestions.length} questions →`}
</button>
)}
</div>
)}
</div>
{/* Expanded full question search results */}
{isSearching && expandedSearch && allSearchQuestions.length > 0 && (
<div>
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: '0.95rem' }}>
All matching questions for "{searchQuery}"
</div>
{allSearchQuestions.map((q, i) => (
<div key={`${q.quiz_id}-${q.id}-${i}`} style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 16px', marginBottom: 8, display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 4 }}>{q.quiz_title}</div>
<div style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.5 }}>
<HighlightText text={q.question_text.slice(0, 200) + (q.question_text.length > 200 ? '…' : '')} query={searchQuery} />
</div>
</div>
<button className="btn btn-sm btn-primary" onClick={() => setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study</button>
</div>
))}
</div>
)}
{/* Search summary results (compact) */}
{isSearching && !expandedSearch && searchResults !== null && (
searchResults.length === 0 ? (
<div className="card"><div className="empty-state">No matches for "{searchQuery}"</div></div>
) : searchResults.map(result => (
<div key={result.quiz_id} className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: result.matching_questions.length > 0 ? 10 : 0 }}>
<div style={{ cursor: 'pointer' }} onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 2 }}>
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{result.questions_count} questions
{result.match_type !== 'title' && ` · ${result.matching_questions.length} question match${result.matching_questions.length !== 1 ? 'es' : ''}`}
</div>
</div>
{isModerator && (
<ConfirmButton label="✕" confirmLabel="Trash?" cancelLabel="✕"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
onConfirm={() => deleteQuiz(result.quiz_id)} />
)}
</div>
{result.matching_questions.slice(0, 2).map((q, i) => (
<div key={q.id} style={{ padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, marginBottom: 6, fontSize: '0.85rem', color: 'var(--text-muted)', borderLeft: '3px solid var(--primary)', display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ flex: 1 }}><HighlightText text={q.question_text.slice(0, 130) + (q.question_text.length > 130 ? '…' : '')} query={searchQuery} /></div>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion({ ...q, quiz_title: result.quiz_title })} style={{ flexShrink: 0 }}>Study</button>
</div>
))}
{result.matching_questions.length > 2 && (
<button className="btn btn-sm btn-secondary" style={{ marginTop: 4 }} onClick={() => setExpandedSearch(true)}>
+{result.matching_questions.length - 2} more view all questions
</button>
)}
</div>
))
)}
{/* In-progress quizzes */}
{!isSearching && <InProgressSection />}
{/* Normal quiz grid */}
{!isSearching && (
<>
{/* Category management (moderators) */}
{isModerator && (
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: categories.length > 0 ? 12 : 0 }}>
<h2 style={{ fontSize: '1rem' }}>Categories</h2>
<button className="btn btn-sm btn-secondary" onClick={() => setAddingCat(v => !v)}>+ Add Category</button>
</div>
{addingCat && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
placeholder="Category name..." onKeyDown={e => e.key === 'Enter' && addCategory()}
style={{ flex: 1, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={addCategory}>Add</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setAddingCat(false); setNewCatName('') }}>Cancel</button>
</div>
)}
{categories.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', background: 'var(--bg)', borderRadius: 20, border: '1px solid var(--border)', fontSize: '0.82rem' }}>
<span>{cat.name}</span>
<span style={{ color: 'var(--text-muted)' }}>({cat.quiz_count})</span>
<button onClick={() => deleteCategory(cat.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.8rem', padding: '0 2px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>
</div>
))}
</div>
)}
</div>
)}
{quizzes.length === 0 ? (
<div className="card"><div className="empty-state">No quizzes yet. Upload a PDF and generate quizzes from sections.</div></div>
) : (
<>
{/* Categorised groups */}
{Object.values(catMap).filter(c => c.quizzes.length > 0).map(cat => (
<div key={cat.id} style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
📁 {cat.name} <span style={{ fontWeight: 400 }}>({cat.quizzes.length})</span>
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16 }}>
{cat.quizzes.map(quiz => <QuizCard key={quiz.id} quiz={quiz} isModerator={isModerator} categories={categories} onDelete={deleteQuiz} onCategoryChange={handleCategoryChange} />)}
</div>
</div>
))}
{/* Uncategorised */}
{uncategorized.length > 0 && (
<div style={{ marginBottom: 24 }}>
{categories.length > 0 && (
<h3 style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
Uncategorized <span style={{ fontWeight: 400 }}>({uncategorized.length})</span>
</h3>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16 }}>
{uncategorized.map(quiz => <QuizCard key={quiz.id} quiz={quiz} isModerator={isModerator} categories={categories} onDelete={deleteQuiz} onCategoryChange={handleCategoryChange} />)}
</div>
</div>
)}
</>
)}
</>
)}
</div>
)
}