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>
446 lines
25 KiB
JavaScript
446 lines
25 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()}>
|
||
<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>
|
||
)
|
||
}
|