Proper question bank system with question categories

Architecture:
- Questions are primary objects in a bank, tagged with question categories
- QuestionCategory is a separate taxonomy from QuizCategory (different concepts)
- Extraction → questions added to bank, optionally tagged to a question category
- Quizzes can be created from: individual question selection, question category, or PDF extraction

Backend:
- QuestionCategory model + question_categories table
- question_category_id column on questions table (nullable, SET NULL on delete)
- GET/POST/PATCH/DELETE /api/question-categories/
- POST /api/question-categories/{id}/create-quiz — create quiz from all questions in a category
- PATCH /api/questions/{id}/category — assign single question to category
- PATCH /api/questions/bulk-category — assign multiple questions at once
- GET /api/questions/bank?category_id=&uncategorized= — filter by category
- QuizCreate schema now accepts question_category_id for extraction
- quiz_service.create_quiz_from_section accepts question_category_id param

Frontend:
- DocumentDetailPage: Add to Bank Category dropdown in Quiz Settings (optional)
  Labels extracted questions with the selected category on creation
- QuestionBankPage: full rewrite
  - Category chips for filtering (All / Uncategorized / named categories)
  - Create category button inline
  - Checkbox multi-select with bulk category assignment
  - Create Quiz modal: choose from selected questions OR all from a category
  - Each question shows its category badge and quiz source
  - Study modal with instant answer feedback

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-31 21:34:39 +02:00
parent 9af63e67b5
commit a7a5bdff62
10 changed files with 508 additions and 148 deletions

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -143,6 +143,21 @@ def setup_pgvector():
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES quiz_categories(id) ON DELETE SET NULL
"""))
# Question categories (separate from quiz categories)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_categories (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
description TEXT,
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS question_category_id INTEGER
REFERENCES question_categories(id) ON DELETE SET NULL
"""))
conn.commit()
@ -223,6 +238,7 @@ app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"])
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
@app.get("/api/health")

View file

@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship
from app.config import settings
from app.database import Base
from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves
class Question(Base):
@ -11,6 +12,7 @@ class Question(Base):
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
question_category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
question_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
options = Column(JSON, nullable=True) # list of strings for mcq
@ -21,3 +23,5 @@ class Question(Base):
embedding = Column(Vector(1024), nullable=True) # semantic search vector
quiz = relationship("Quiz", back_populates="questions")
question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id])

View file

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.database import Base
class QuestionCategory(Base):
__tablename__ = "question_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
questions = relationship("Question", back_populates="question_category",
foreign_keys="Question.question_category_id")

View file

@ -0,0 +1,158 @@
"""Question category management — organise bank questions by topic/subject."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
class QCatCreate(BaseModel):
name: str
description: str | None = None
class QCatResponse(BaseModel):
id: int
name: str
description: str | None
question_count: int = 0
model_config = {"from_attributes": True}
@router.get("/", response_model=list[QCatResponse])
def list_question_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
cats = db.query(QuestionCategory).order_by(QuestionCategory.name).all()
return [
QCatResponse(
id=c.id, name=c.name, description=c.description,
question_count=db.query(Question).filter(Question.question_category_id == c.id).count(),
)
for c in cats
]
@router.post("/", response_model=QCatResponse)
def create_question_category(
data: QCatCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
if not data.name.strip():
raise HTTPException(status_code=400, detail="Name cannot be empty")
existing = db.query(QuestionCategory).filter(QuestionCategory.name == data.name.strip()).first()
if existing:
raise HTTPException(status_code=400, detail="Category already exists")
cat = QuestionCategory(name=data.name.strip(), description=data.description, user_id=current_user.id)
db.add(cat)
db.commit()
db.refresh(cat)
return QCatResponse(id=cat.id, name=cat.name, description=cat.description, question_count=0)
@router.patch("/{cat_id}", response_model=QCatResponse)
def update_question_category(
cat_id: int,
data: QCatCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
cat = db.query(QuestionCategory).filter(QuestionCategory.id == cat_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
cat.name = data.name.strip()
cat.description = data.description
db.commit()
count = db.query(Question).filter(Question.question_category_id == cat_id).count()
return QCatResponse(id=cat.id, name=cat.name, description=cat.description, question_count=count)
@router.delete("/{cat_id}", status_code=204)
def delete_question_category(
cat_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
cat = db.query(QuestionCategory).filter(QuestionCategory.id == cat_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
# Unassign questions
db.query(Question).filter(Question.question_category_id == cat_id).update(
{"question_category_id": None}
)
db.delete(cat)
db.commit()
@router.post("/{cat_id}/create-quiz")
def create_quiz_from_question_category(
cat_id: int,
title: str,
mode: str = "timed",
time_limit_minutes: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Create a new quiz from all questions in a question category."""
cat = db.query(QuestionCategory).filter(QuestionCategory.id == cat_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
if not title.strip():
raise HTTPException(status_code=400, detail="Title is required")
if mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be timed or learning")
source_questions = db.query(Question).filter(
Question.question_category_id == cat_id
).order_by(Question.id).all()
if not source_questions:
raise HTTPException(status_code=400, detail="This category has no questions")
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first()
if not first_quiz:
raise HTTPException(status_code=400, detail="Cannot determine source section")
from app.services import embedding_service
new_quiz = Quiz(
section_id=first_quiz.section_id,
user_id=current_user.id,
title=title.strip(),
questions_count=len(source_questions),
mode=mode,
time_limit_minutes=time_limit_minutes,
)
db.add(new_quiz)
db.flush()
for sq in source_questions:
new_q = Question(
quiz_id=new_quiz.id,
question_category_id=sq.question_category_id,
question_text=sq.question_text,
question_type=sq.question_type,
options=sq.options,
correct_answer=sq.correct_answer,
explanation=sq.explanation,
page_reference=sq.page_reference,
)
db.add(new_q)
db.flush()
try:
embedding_service.embed_question(new_q)
except Exception:
pass
db.commit()
db.refresh(new_quiz)
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}

View file

@ -1,4 +1,4 @@
"""Question bank — view, search, and create quizzes from individual questions."""
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import cast, String, or_
@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
@ -13,10 +14,51 @@ from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.patch("/{question_id}/category")
def set_question_category(
question_id: int,
category_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Assign or remove a question category."""
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
if category_id is not None:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
question.question_category_id = category_id
db.commit()
return {"question_id": question_id, "question_category_id": category_id}
@router.patch("/bulk-category")
def bulk_set_question_category(
question_ids: list[int],
category_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Assign a category to multiple questions at once."""
if category_id is not None:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
updated = db.query(Question).filter(Question.id.in_(question_ids)).update(
{"question_category_id": category_id}, synchronize_session=False
)
db.commit()
return {"updated": updated, "question_category_id": category_id}
@router.get("/bank")
def get_question_bank(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
@ -28,6 +70,12 @@ def get_question_bank(
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
if q and q.strip():
phrase = q.strip()
query = query.filter(
@ -40,17 +88,25 @@ def get_question_bank(
total = query.count()
questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all()
# Attach quiz title
quiz_cache: dict[int, str] = {}
cat_cache: dict[int, str] = {}
result = []
for qu in questions:
if qu.quiz_id not in quiz_cache:
quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first()
quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}"
cat_name = None
if qu.question_category_id:
if qu.question_category_id not in cat_cache:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == qu.question_category_id).first()
cat_cache[qu.question_category_id] = cat.name if cat else None
cat_name = cat_cache.get(qu.question_category_id)
result.append({
"id": qu.id,
"quiz_id": qu.quiz_id,
"quiz_title": quiz_cache[qu.quiz_id],
"question_category_id": qu.question_category_id,
"question_category_name": cat_name,
"question_text": qu.question_text,
"question_type": qu.question_type,
"options": qu.options,

View file

@ -41,6 +41,7 @@ def create_quiz(
mode=quiz_data.mode,
time_limit_minutes=quiz_data.time_limit_minutes,
model_id=quiz_data.model_id,
question_category_id=quiz_data.question_category_id,
)
return quiz
except ValueError as e:

View file

@ -9,6 +9,7 @@ class QuizCreate(BaseModel):
mode: str = "timed" # timed, learning
time_limit_minutes: int | None = None
model_id: str | None = None # override extraction model
question_category_id: int | None = None # assign extracted questions to this bank category
class QuestionResponse(BaseModel):

View file

@ -21,6 +21,7 @@ def create_quiz_from_section(
mode: str = "timed",
time_limit_minutes: int | None = None,
model_id: str | None = None,
question_category_id: int | None = None,
) -> Quiz:
"""Extract questions from a section's page range using AI."""
section = db.query(Section).filter(Section.id == section_id).first()
@ -103,6 +104,7 @@ def create_quiz_from_section(
question = Question(
quiz_id=quiz.id,
question_category_id=question_category_id,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),

View file

@ -17,6 +17,8 @@ export default function DocumentDetailPage() {
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
const [selectedModelId, setSelectedModelId] = useState('')
const [availableModels, setAvailableModels] = useState([])
const [questionCategories, setQuestionCategories] = useState([])
const [selectedQuestionCategoryId, setSelectedQuestionCategoryId] = useState('')
const [error, setError] = useState('')
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@ -30,13 +32,15 @@ export default function DocumentDetailPage() {
useEffect(() => {
fetchDoc()
api.get('/admin/models/available?task=extraction')
.then(res => {
setAvailableModels(res.data)
const def = res.data.find(m => m.is_default)
if (def) setSelectedModelId(def.model_id)
})
.catch(() => {})
Promise.all([
api.get('/admin/models/available?task=extraction').catch(() => ({ data: [] })),
api.get('/question-categories/').catch(() => ({ data: [] })),
]).then(([modelsRes, catsRes]) => {
setAvailableModels(modelsRes.data)
const def = modelsRes.data.find(m => m.is_default)
if (def) setSelectedModelId(def.model_id)
setQuestionCategories(catsRes.data)
})
}, [id])
useEffect(() => {
@ -78,6 +82,7 @@ export default function DocumentDetailPage() {
mode: quizMode,
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
model_id: selectedModelId || null,
question_category_id: selectedQuestionCategoryId ? parseInt(selectedQuestionCategoryId) : null,
})
navigate(`/quizzes/${res.data.id}`)
} catch (err) {
@ -209,6 +214,18 @@ export default function DocumentDetailPage() {
</select>
</div>
)}
<div className="form-group">
<label>Add Questions to Bank Category <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<select value={selectedQuestionCategoryId} onChange={e => setSelectedQuestionCategoryId(e.target.value)}>
<option value=""> No category </option>
{questionCategories.map(c => (
<option key={c.id} value={c.id}>{c.name} ({c.question_count} questions)</option>
))}
</select>
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
Extracted questions will be added to the Question Bank and tagged with this category.
</p>
</div>
</div>
</div>
)}

View file

@ -5,67 +5,114 @@ import api from '../api/client'
function QuestionStudyModal({ question, onClose }) {
const [answered, setAnswered] = useState(null)
const isCorrect = answered === question.correct_answer
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px',
}} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{
background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: '24px',
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', alignItems: 'flex-start', marginBottom: 16 }}>
<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' }}>
From: {question.quiz_title}
{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', lineHeight: 1 }}></button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontWeight: 600, fontSize: '1rem', lineHeight: 1.6, marginBottom: 16, color: 'var(--text)' }}>
{question.question_text}
</p>
<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
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
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">{letter}</span>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}>{opt}</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</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>
<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>
)
}
{answered && (
<div style={{ marginTop: 16, 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>
)}
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>
)
@ -75,177 +122,218 @@ export default function QuestionBankPage() {
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false)
const [offset, setOffset] = useState(0)
const [selected, setSelected] = useState(null)
const [creating, setCreating] = useState(false)
const [studyQuestion, setStudyQuestion] = useState(null)
const [selectedIds, setSelectedIds] = useState(new Set())
const [createForm, setCreateForm] = useState({ title: '', mode: 'timed', time_limit_minutes: '' })
const [createError, setCreateError] = useState('')
const { user } = useAuth()
const navigate = useNavigate()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [showCreateQuiz, setShowCreateQuiz] = useState(false)
const [newCatName, setNewCatName] = useState('')
const [showCatForm, setShowCatForm] = useState(false)
const [assignCatId, setAssignCatId] = useState('')
const debounceRef = useRef(null)
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = 50
const load = async (query = searchQuery, off = offset) => {
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
}, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized) => {
setLoading(true)
try {
const params = { limit: LIMIT, offset: off }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true
const res = await api.get('/questions/bank', { params })
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
setTotal(res.data.total)
} catch (err) {
console.error(err)
} finally { setLoading(false) }
setOffset(off)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { setOffset(0); load(searchQuery, 0) }, 300)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized), 300)
return () => clearTimeout(debounceRef.current)
}, [searchQuery])
}, [searchQuery, filterCatId, showUncategorized])
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
const selectAll = () => setSelectedIds(new Set(questions.map(q => q.id)))
const clearSelection = () => setSelectedIds(new Set())
const bulkAssignCategory = async () => {
if (!assignCatId && assignCatId !== '0') return
const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId)
try {
await api.patch('/questions/bulk-category', [...selectedIds], { params: { category_id: catIdVal ?? '' } })
setQuestions(prev => prev.map(q => selectedIds.has(q.id)
? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null }
: q))
clearSelection()
setAssignCatId('')
api.get('/question-categories/').then(res => setCategories(res.data))
} catch { }
}
const createQuiz = async () => {
setCreateError('')
if (!createForm.title.trim()) return setCreateError('Title is required')
const addCategory = async () => {
if (!newCatName.trim()) return
try {
const res = await api.post('/questions/from-bank', {
title: createForm.title,
question_ids: [...selectedIds],
mode: createForm.mode,
time_limit_minutes: createForm.time_limit_minutes ? parseInt(createForm.time_limit_minutes) : null,
})
navigate(`/quizzes/${res.data.id}`)
} catch (err) {
setCreateError(err.response?.data?.detail || 'Failed to create quiz')
}
const res = await api.post('/question-categories/', { name: newCatName.trim() })
setCategories(prev => [...prev, res.data])
setNewCatName(''); setShowCatForm(false)
} catch (err) { alert(err.response?.data?.detail || 'Failed') }
}
const deleteCategory = async (catId) => {
if (!confirm('Delete this question category? Questions will become uncategorized.')) return
try {
await api.delete(`/question-categories/${catId}`)
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized)
} catch { }
}
return (
<div>
{selected && <QuestionStudyModal question={selected} onClose={() => setSelected(null)} />}
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(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: 'center', flexWrap: 'wrap', gap: 12 }}>
<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 across all quizzes</p>
<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>
{isModerator && selectedIds.size > 0 && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
Create Quiz from {selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''}
</button>
)}
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ flex: 1, 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)' }}
/>
{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>
{isModerator && selectedIds.size > 0 && (
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedIds(new Set())}>Clear selection</button>
)}
</div>
)}
</div>
{/* Create quiz modal */}
{creating && (
<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: 16 }}>Create Quiz from Bank</h2>
{createError && <div className="alert alert-error">{createError}</div>}
<div className="form-group">
<label>Quiz Title</label>
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter quiz title..." />
{/* Category chips */}
{categories.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId('') }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false) }}>
{cat.name} <span style={{ opacity: 0.7 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.75rem', padding: '2px 4px' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
<div className="form-group">
<label>Mode</label>
<select value={createForm.mode} onChange={e => setCreateForm(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={createForm.time_limit_minutes} onChange={e => setCreateForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} />
</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: 16 }}>
{selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''} selected. Original questions remain unchanged.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={createQuiz}>Create Quiz</button>
<button className="btn btn-secondary" onClick={() => { setCreating(false); setCreateError('') }}>Cancel</button>
</div>
</div>
))}
</div>
)}
{/* Search + bulk actions */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, 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>
{isModerator && (
<>
<button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={questions.length === 0}>Select all</button>
{selectedIds.size > 0 && <button className="btn btn-sm btn-secondary" onClick={clearSelection}>Clear ({selectedIds.size})</button>}
{selectedIds.size > 0 && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select value={assignCatId} onChange={e => setAssignCatId(e.target.value)}
style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">Assign category</option>
<option value="0">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>
</div>
)}
</>
)}
</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: '16px 18px', marginBottom: 10,
display: 'flex', gap: 12, alignItems: 'flex-start',
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={{ fontSize: '0.72rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: 4 }}>
From: {q.quiz_title}
<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.9rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 8 : 0 }}>
<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: 6 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{q.options.map((opt, i) => (
<span key={i} style={{
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 4,
fontSize: '0.72rem', padding: '2px 7px', borderRadius: 4,
background: opt === q.correct_answer ? 'var(--correct-bg)' : 'var(--bg)',
color: opt === q.correct_answer ? 'var(--correct-fg)' : 'var(--text-muted)',
border: `1px solid ${opt === q.correct_answer ? 'var(--correct-bd)' : 'var(--border)'}`,
}}>
{String.fromCharCode(65 + i)}. {opt.slice(0, 40)}{opt.length > 40 ? '…' : ''}
{String.fromCharCode(65 + i)}. {opt.slice(0, 45)}{opt.length > 45 ? '…' : ''}
</span>
))}
</div>
)}
</div>
<button className="btn btn-sm btn-secondary" onClick={() => setSelected(q)} style={{ flexShrink: 0 }}>
Study
</button>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study</button>
</div>
))}
{questions.length < total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button className="btn btn-secondary" onClick={() => { const next = offset + LIMIT; setOffset(next); load(searchQuery, next) }} disabled={loading}>
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
</button>
</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)} disabled={loading}>
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
</button>
</div>
)}
</div>
)
}