- 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>
574 lines
31 KiB
JavaScript
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>
|
|
)
|
|
}
|