diff --git a/backend/app/main.py b/backend/app/main.py index 87e383c..cf4070f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/question.py b/backend/app/models/question.py index 9b7d5b2..20fa056 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -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]) diff --git a/backend/app/models/question_category.py b/backend/app/models/question_category.py new file mode 100644 index 0000000..f7ec9b6 --- /dev/null +++ b/backend/app/models/question_category.py @@ -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") diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py new file mode 100644 index 0000000..19df85c --- /dev/null +++ b/backend/app/routers/question_categories.py @@ -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} diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index 3abbecd..71578a7 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -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, diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 05a88de..dfa2772 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -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: diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 2313037..d52ba76 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -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): diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py index 8e45684..86ec7d7 100644 --- a/backend/app/services/quiz_service.py +++ b/backend/app/services/quiz_service.py @@ -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"), diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index fad2cab..518fa08 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -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() { )} +
+ + +

+ Extracted questions will be added to the Question Bank and tagged with this category. +

+
)} diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index d2232f6..23d63f3 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -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 ( -
e.target === e.currentTarget && onClose()}> -
-
+
e.target === e.currentTarget && onClose()}> +
+
- From: {question.quiz_title} + {question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''} - +
- -

- {question.question_text} -

- +

{question.question_text}

{question.options && (
{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 (
!hasAnswered && setAnswered(opt)} style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> - {letter} + {String.fromCharCode(65 + i)} {opt} - {showCorrect && ✓ Correct} - {showWrong && ✗ Wrong} + {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'}} + {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong}
) })}
)} - {answered && question.explanation && ( -
- Explanation: -
{question.explanation}
-
+
Explanation:
{question.explanation}
)} + {answered &&
+ + +
} +
+
+ ) +} - {answered && ( -
- - -
- )} +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 ( +
+
+

Create Quiz

+ {error &&
{error}
} +
+ + +
+
+ + setForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter title..." /> +
+
+ + +
+
+ + setForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} /> +
+
+ + +
) @@ -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 (
- {selected && setSelected(null)} />} + {studyQuestion && setStudyQuestion(null)} />} + {showCreateQuiz && setShowCreateQuiz(false)} onCreated={() => {}} />} + {/* Header */}
-
+

Question Bank

-

{total} questions across all quizzes

+

{total} questions total

+
+
+ {isModerator && selectedIds.size > 0 && ( + + )} + {isModerator && }
- {isModerator && selectedIds.size > 0 && ( - - )}
-
-
- 🔍 - 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 && ( +
+ 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)' }} /> + +
- {isModerator && selectedIds.size > 0 && ( - - )} -
+ )}
- {/* Create quiz modal */} - {creating && ( -
-
-

Create Quiz from Bank

- {createError &&
{createError}
} -
- - setCreateForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter quiz title..." /> + {/* Category chips */} + {categories.length > 0 && ( +
+ + + {categories.map(cat => ( +
+ + {isModerator && }
-
- - -
-
- - setCreateForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} /> -
-

- {selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''} selected. Original questions remain unchanged. -

-
- - -
-
+ ))}
)} + {/* Search + bulk actions */} +
+
+ 🔍 + 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)' }} /> +
+ {isModerator && ( + <> + + {selectedIds.size > 0 && } + {selectedIds.size > 0 && ( +
+ + +
+ )} + + )} +
+ + {/* Question list */} {loading && questions.length === 0 &&
} {questions.map(q => (
{isModerator && ( toggleSelect(q.id)} style={{ marginTop: 4, accentColor: 'var(--primary)', flexShrink: 0 }} /> )}
-
- From: {q.quiz_title} +
+ From: {q.quiz_title} + {q.question_category_name && ( + + {q.question_category_name} + + )}
-

+

{q.question_text.slice(0, 200)}{q.question_text.length > 200 ? '…' : ''}

{q.options && ( -
+
{q.options.map((opt, i) => ( - {String.fromCharCode(65 + i)}. {opt.slice(0, 40)}{opt.length > 40 ? '…' : ''} + {String.fromCharCode(65 + i)}. {opt.slice(0, 45)}{opt.length > 45 ? '…' : ''} ))}
)}
- +
))} - {questions.length < total && ( -
- -
- )} - {!loading && questions.length === 0 && (
No questions found.
)} + + {questions.length < total && ( +
+ +
+ )}
) }