pdf-quiz-generator/frontend/src/pages/QuestionBankPage.jsx
Daniel 975a31fb01 TeachChat: model selector, proxy routing fix, titan seed cleanup
- teach.py: use _proxy_model() + pass api_key/api_base from settings (fixes LiteLLM provider error for openrouter/bedrock models)
- teach.py: accept model_id in ChatRequest so frontend can select model
- main.py: remove titan-embed-v2 from general seed, auto-delete legacy entry on startup
- main.py: kill stale idle-in-transaction DB connections at startup to prevent DDL lock hangs
- main.py: set lock_timeout=10s on DDL connection as fast-fail safety net
- TeachChat.jsx: fetch /teach/models, show selector dropdown in header when >1 model available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:37:35 +02:00

574 lines
31 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
function QuestionStudyModal({ question, 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' }}>
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
</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 }}>{question.question_text}</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 }}>{opt}</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: 12, 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 CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
const [form, setForm] = useState({ title: '', mode: 'timed', time_limit_minutes: '', question_category_id: '' })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const submit = async () => {
if (!form.title.trim()) return setError('Title is required')
setLoading(true)
try {
if (form.question_category_id) {
const res = await api.post(`/question-categories/${form.question_category_id}/create-quiz`, null, {
params: {
title: form.title,
mode: form.mode,
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
}
})
navigate(`/quizzes/${res.data.id}`)
} else {
const res = await api.post('/questions/from-bank', {
title: form.title,
question_ids: [...selectedIds],
mode: form.mode,
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
})
navigate(`/quizzes/${res.data.id}`)
}
} catch (err) { setError(err.response?.data?.detail || 'Failed') }
finally { setLoading(false) }
}
const fromCategory = !!form.question_category_id
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 420, width: '100%' }}>
<h2 style={{ marginBottom: 16 }}>Create Quiz</h2>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Source</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}>
<option value="">{selectedIds.size} selected question{selectedIds.size !== 1 ? 's' : ''}</option>
{categories.map(c => <option key={c.id} value={c.id}>All from: {c.name} ({c.question_count} questions)</option>)}
</select>
</div>
<div className="form-group">
<label>Quiz Title</label>
<input type="text" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter title..." />
</div>
<div className="form-group">
<label>Mode</label>
<select value={form.mode} onChange={e => setForm(f => ({ ...f, mode: e.target.value }))}>
<option value="timed">Timed (Exam)</option>
<option value="learning">Learning (Study)</option>
</select>
</div>
<div className="form-group">
<label>Time Limit (minutes, optional)</label>
<input type="number" value={form.time_limit_minutes} onChange={e => setForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={submit} disabled={loading}>{loading ? 'Creating…' : 'Create Quiz'}</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}
const LETTERS = ['A','B','C','D','E','F']
function QuestionEditModal({ question, categories, onSaved, onClose }) {
const [form, setForm] = useState({
question_text: question.question_text,
question_type: question.question_type,
options: question.options ? [...question.options] : [],
correct_answer: question.correct_answer,
explanation: question.explanation || '',
question_category_id: question.question_category_id || '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
}
const save = async () => {
if (!form.question_text.trim()) return setError('Question text is required')
if (form.options.length > 0 && !form.options.includes(form.correct_answer))
return setError('Correct answer must match one of the options')
setSaving(true); setError('')
try {
const payload = { ...form, question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null }
// Update via quiz edit endpoint (uses source quiz for routing)
const res = await api.patch(`/quizzes/${question.quiz_id}/questions/${question.id}`, payload)
// Also update category if changed
if (form.question_category_id !== (question.question_category_id || '')) {
await api.patch(`/questions/${question.id}/category`, null, {
params: { category_id: form.question_category_id ? parseInt(form.question_category_id) : '' }
})
}
onSaved({ ...question, ...res.data, question_category_id: payload.question_category_id,
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
onClose()
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
finally { setSaving(false) }
}
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: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginBottom: 16 }}>
This question is shared changes apply to all quizzes using it.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
{form.options.length > 0 && (
<div className="form-group">
<label>Options select the correct one</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="correct" checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Question Category</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}>
<option value=""> Uncategorized </option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Explanation</label>
<textarea rows={6} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}
export default function QuestionBankPage() {
const { dialogProps, openAlert } = useDialog()
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false)
const [showFavorites, setShowFavorites] = useState(false)
const [favorites, setFavorites] = useState([])
const [offset, setOffset] = useState(0)
const [studyQuestion, setStudyQuestion] = useState(null)
const [editQuestion, setEditQuestion] = useState(null)
const [selectedIds, setSelectedIds] = useState(new Set())
const [showCreateQuiz, setShowCreateQuiz] = useState(false)
const [newCatName, setNewCatName] = useState('')
const [showCatForm, setShowCatForm] = useState(false)
const [assignCatId, setAssignCatId] = useState('')
const [bulkError, setBulkError] = useState('')
const [pageSize, setPageSize] = useState(50)
const debounceRef = useRef(null)
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = pageSize
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {})
}, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize) => {
setLoading(true)
try {
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true
if (favOnly) params.favorites_only = true
const res = await api.get('/questions/bank', { params })
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
setTotal(res.data.total)
setOffset(off)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, showFavorites, searchMode, pageSize), 300)
return () => clearTimeout(debounceRef.current)
}, [searchQuery, filterCatId, showUncategorized, showFavorites, searchMode, pageSize])
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
const selectAll = async () => {
// Fetch ALL matching IDs from server (not just loaded page)
try {
const params = {}
if (searchQuery.trim()) params.q = searchQuery.trim()
if (filterCatId) params.category_id = parseInt(filterCatId)
if (showUncategorized) params.uncategorized = true
if (showFavorites) params.favorites_only = true
const res = await api.get('/questions/bank/ids', { params })
setSelectedIds(new Set(res.data))
} catch {
// Fallback to loaded questions
setSelectedIds(new Set(questions.map(q => q.id)))
}
}
const clearSelection = () => setSelectedIds(new Set())
const bulkAssignCategory = async () => {
if (!assignCatId) return
setBulkError('')
const catIdVal = assignCatId === 'remove' ? null : parseInt(assignCatId)
try {
await api.post('/questions/bulk-category', {
question_ids: [...selectedIds],
category_id: catIdVal,
})
const catName = catIdVal ? categories.find(c => c.id === catIdVal)?.name ?? null : null
setQuestions(prev => prev.map(q => selectedIds.has(q.id)
? { ...q, question_category_id: catIdVal, question_category_name: catName }
: q))
clearSelection()
setAssignCatId('')
api.get('/question-categories/').then(res => setCategories(res.data))
} catch (err) {
setBulkError(err.response?.data?.detail || 'Failed to assign category')
}
}
const addCategory = async () => {
if (!newCatName.trim()) return
try {
const res = await api.post('/question-categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data])
setNewCatName(''); setShowCatForm(false)
} catch (err) { await openAlert(err.response?.data?.detail || 'Failed', { title: 'Error' }) }
}
const [deletingCatId, setDeletingCatId] = useState(null)
const [moveToCatId, setMoveToCatId] = useState('')
const confirmDeleteCategory = async () => {
const catId = deletingCatId
const moveTo = moveToCatId ? parseInt(moveToCatId) : undefined
setDeletingCatId(null); setMoveToCatId('')
try {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites)
} catch { }
}
const deleteCategory = (catId) => {
setDeletingCatId(catId)
setMoveToCatId('')
}
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
try {
if (isFavorited) {
await api.delete(`/favorites/${questionId}`)
setFavorites(prev => prev.filter(id => id !== questionId))
} else {
await api.post('/favorites', { question_id: questionId })
setFavorites(prev => [...prev, questionId])
}
} catch (err) {
await openAlert(err.response?.data?.detail || 'Failed to update favorite', { title: 'Error' })
}
}
return (
<div>
<Dialog {...dialogProps} />
{/* Delete category dialog */}
{deletingCatId && (() => {
const cat = categories.find(c => c.id === deletingCatId)
const others = categories.filter(c => c.id !== deletingCatId)
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 400, width: '100%' }}>
<h2 style={{ marginBottom: 12, fontSize: '1.1rem' }}>Delete "{cat?.name}"?</h2>
{cat?.question_count > 0 && (
<div className="form-group">
<label>Move {cat.question_count} questions to:</label>
<select value={moveToCatId} onChange={e => setMoveToCatId(e.target.value)}>
<option value="">Leave uncategorized</option>
{others.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="btn btn-danger" onClick={confirmDeleteCategory}>Delete category</button>
<button className="btn btn-secondary" onClick={() => { setDeletingCatId(null); setMoveToCatId('') }}>Cancel</button>
</div>
</div>
</div>
)
})()}
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
onClose={() => setEditQuestion(null)} />}
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ marginBottom: 4 }}>Question Bank</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{total} questions total</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{isModerator && selectedIds.size > 0 && (
<button className="btn btn-primary btn-sm" onClick={() => setShowCreateQuiz(true)}>
Create Quiz ({selectedIds.size} selected)
</button>
)}
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div>
</div>
{showCatForm && (
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="New 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={() => { setShowCatForm(false); setNewCatName('') }}>Cancel</button>
</div>
)}
</div>
{/* Category filter chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowFavorites(v => !v); setFilterCatId(''); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId(''); setShowFavorites(false) }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false); setShowFavorites(false) }}>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
))}
</div>
{/* Search row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<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 questions..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
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>
{/* Search mode — clearly labelled separately from category chips */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
<span>Search:</span>
<select value={searchMode} onChange={e => setSearchMode(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="hybrid">Keyword + Semantic</option>
<option value="keyword">Keyword only</option>
<option value="semantic">Semantic (AI)</option>
</select>
</div>
{/* Page size */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
<span>Show:</span>
<select value={pageSize} onChange={e => setPageSize(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
style={{ padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
<option value="all">All</option>
</select>
</div>
</div>
{/* Bulk actions row — only shown for moderators, clearly separated */}
{isModerator && (
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8 }}>
<button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={loading}>
Select all {total > 0 ? `(${total})` : ''}
</button>
{selectedIds.size > 0 && (
<>
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{selectedIds.size} selected</span>
<button className="btn btn-sm btn-secondary" onClick={clearSelection}>Clear</button>
<select value={assignCatId} onChange={e => setAssignCatId(e.target.value)}
style={{ padding: '5px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">Move to category</option>
<option value="remove"> Remove category</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button className="btn btn-sm btn-primary" onClick={bulkAssignCategory} disabled={!assignCatId}>Apply</button>
<button className="btn btn-sm btn-primary" onClick={() => setShowCreateQuiz(true)}>Create Quiz</button>
</>
)}
{bulkError && <span style={{ fontSize: '0.8rem', color: 'var(--wrong-fg)' }}>{bulkError}</span>}
</div>
)}
{/* Question list */}
{loading && questions.length === 0 && <div className="loading"><div className="spinner" /></div>}
{questions.map(q => (
<div key={q.id} style={{
background: 'var(--card-bg)',
border: `1.5px solid ${selectedIds.has(q.id) ? 'var(--primary)' : 'var(--border)'}`,
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
{isModerator && (
<input type="checkbox" checked={selectedIds.has(q.id)} onChange={() => toggleSelect(q.id)}
style={{ marginTop: 4, accentColor: 'var(--primary)', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>From: {q.quiz_title}</span>
{q.question_category_name && (
<span style={{ fontSize: '0.72rem', fontWeight: 600, padding: '1px 7px', borderRadius: 10, background: 'var(--option-sel-bg)', color: 'var(--primary)' }}>
{q.question_category_name}
</span>
)}
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 6 : 0 }}>
{q.question_text.slice(0, 200)}{q.question_text.length > 200 ? '…' : ''}
</p>
{q.options && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{q.options.map((opt, i) => (
<span key={i} style={{
fontSize: '0.72rem', padding: '2px 7px', borderRadius: 4,
background: 'var(--bg)', color: 'var(--text-muted)',
border: '1px solid var(--border)',
}}>
{String.fromCharCode(65 + i)}. {opt.slice(0, 50)}{opt.length > 50 ? '…' : ''}
</span>
))}
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
<button
onClick={() => toggleFavorite(q.id)}
title={favorites.includes(q.id) ? 'Remove from favorites' : 'Add to favorites'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.3rem',
padding: 4,
lineHeight: 1,
}}
>
{favorites.includes(q.id) ? '⭐' : '☆'}
</button>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
</div>
</div>
))}
{!loading && questions.length === 0 && (
<div className="card"><div className="empty-state">No questions found.</div></div>
)}
{questions.length < total && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized, showFavorites)} disabled={loading}>
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
</button>
</div>
)}
</div>
)
}