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:
parent
9af63e67b5
commit
a7a5bdff62
10 changed files with 508 additions and 148 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
17
backend/app/models/question_category.py
Normal file
17
backend/app/models/question_category.py
Normal 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")
|
||||
158
backend/app/routers/question_categories.py
Normal file
158
backend/app/routers/question_categories.py
Normal 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}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue