pdf-quiz-generator/frontend/src/pages/QuizzesPage.jsx
2026-05-12 16:41:44 +02:00

538 lines
29 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, lazy, Suspense } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog'
import InProgressQuizzes from '../components/InProgressQuizzes'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
function PastAttemptsSection() {
const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null)
const [loading, setLoading] = useState(false)
const [deletingId, setDeletingId] = useState(null)
const load = async () => {
if (history !== null) { setOpen(v => !v); return }
setLoading(true)
try {
const res = await api.get('/attempts/history')
setHistory(res.data)
} catch { setHistory([]) }
finally { setLoading(false); setOpen(true) }
}
const deleteAttempt = async (attemptId) => {
setDeletingId(attemptId)
try {
await api.delete(`/attempts/${attemptId}`)
setHistory(prev =>
prev
.map(q => ({ ...q, attempts: q.attempts.filter(a => a.attempt_id !== attemptId) }))
.filter(q => q.attempts.length > 0)
)
} catch { }
finally { setDeletingId(null) }
}
const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0
return (
<div className="card" style={{ marginBottom: 16 }}>
<button onClick={load} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
width: '100%', background: 'none', border: 'none', cursor: 'pointer',
padding: 0, textAlign: 'left',
}}>
<h2 style={{ fontSize: '1rem', color: 'var(--text)', margin: 0 }}>
Past Attempts {history !== null && `(${totalCompleted})`}
</h2>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{loading ? 'Loading...' : open ? '▲ Hide' : '▼ Show'}
</span>
</button>
{open && history !== null && (
<div style={{ marginTop: 12 }}>
{history.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No completed attempts yet.</div>
) : history.map(quiz => (
<div key={quiz.quiz_id} style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6 }}>{quiz.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{quiz.attempts.map((a) => (
<div key={a.attempt_id} style={{
display: 'flex', alignItems: 'center',
padding: '7px 12px', background: 'var(--bg)', borderRadius: 8,
fontSize: '0.82rem', gap: 8,
}}>
<span style={{ color: 'var(--text-muted)', flex: 1 }}>
{new Date(a.date).toLocaleDateString()} {a.score}/{a.total}
</span>
<span style={{
fontWeight: 700, fontSize: '0.85rem',
color: a.percentage >= 80 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)',
}}>{a.percentage}%</span>
<a href={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', fontSize: '0.78rem', textDecoration: 'none', flexShrink: 0 }}>
Review
</a>
<button
onClick={() => deleteAttempt(a.attempt_id)}
disabled={deletingId === a.attempt_id}
title="Delete this attempt"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--wrong-fg)', fontSize: '0.78rem',
padding: '2px 4px', borderRadius: 4, flexShrink: 0,
opacity: deletingId === a.attempt_id ? 0.4 : 0.6,
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = deletingId === a.attempt_id ? '0.4' : '0.6'}
>
{deletingId === a.attempt_id ? '…' : '✕'}
</button>
</div>
))}
</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.image_path && (
<div style={{ margin: '0 0 14px' }}>
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
{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>
{/* AI Tutor — z-index above the modal */}
<Suspense fallback={null}>
<TeachChat question={question} elevated />
</Suspense>
</>
)
}
function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange }) {
const navigate = useNavigate()
const [showCatMenu, setShowCatMenu] = useState(false)
const assignCategory = async (catId) => {
try {
const params = catId == null ? {} : { category_id: catId }
await api.patch(`/categories/quizzes/${quiz.id}`, null, { params })
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 { dialogProps, openAlert } = useDialog()
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) { await openAlert(err.response?.data?.detail || 'Failed to create category', { title: 'Error' }) }
}
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>
<Dialog {...dialogProps} />
{studyQuestion && (
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
)}
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #229ed9', display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>Use our Telegram bot</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Start quick random quizzes, browse categories, and study questions from Telegram.
</div>
</div>
<a
href="https://t.me/pedshubbot"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{ textDecoration: 'none', flexShrink: 0 }}
>
Open @pedshubbot
</a>
</div>
{/* 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 && <InProgressQuizzes />}
{/* Past attempts (loads on demand) */}
{!isSearching && <PastAttemptsSection />}
{/* 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>
)
}