pdf-quiz-generator/frontend/src/pages/QuizzesPage.jsx
Daniel 84c4917d91 In-progress quizzes panel, navigation warning, attempt reuse, progress via Redis
In-progress quizzes section (Quizzes page):
- Shows all incomplete (not submitted) attempts in an amber ⏸ panel
- Each shows quiz title, start date, question count
- Resume button → navigates to quiz (which loads saved answers from Redis)
- Delete button (ConfirmButton) → deletes attempt + clears reminders

Quiz navigation warning:
- beforeunload handler warns when closing browser tab/window mid-quiz
- Leave confirmation modal when navigating away via React Router
  (shows "Your progress is saved — resume later" + Stay/Leave buttons)

Quiz progress:
- Scroll on Next/Previous REMOVED per user request — page no longer scrolls
- Progress saved to Redis on every answer/navigation (1.5s debounce)
- Works across browsers, survives logout
- ModeSelectScreen loads saved progress from API and shows Resume button

Attempt management:
- POST /attempts/start now REUSES existing incomplete attempt (no more
  creating duplicate incomplete attempts on every quiz start)
  Pass fresh=true to force a new attempt
- GET /attempts/in-progress — list incomplete attempts for current user
- DELETE /attempts/{id} — delete attempt + reminders

Cleaned up 10 duplicate incomplete attempts from the database

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 05:16:14 +02:00

442 lines
24 KiB
JavaScript

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