diff --git a/backend/alembic/versions/c3d4e5f6a7b8_question_feedback.py b/backend/alembic/versions/c3d4e5f6a7b8_question_feedback.py
new file mode 100644
index 0000000..c21d3fd
--- /dev/null
+++ b/backend/alembic/versions/c3d4e5f6a7b8_question_feedback.py
@@ -0,0 +1,44 @@
+"""Feedback on a question, replacing the comment thread
+
+A comment thread under every question is a discussion that needs moderating.
+What people used it for was telling an educator something was wrong, which is a
+private report someone is meant to act on. This is that.
+
+Revision ID: c3d4e5f6a7b8
+Revises: b2c3d4e5f6a7
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "c3d4e5f6a7b8"
+down_revision = "b2c3d4e5f6a7"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Guarded: create_all() may have built it already on a fresh deploy.
+ if "question_feedback" in sa.inspect(op.get_bind()).get_table_names():
+ return
+ op.create_table(
+ "question_feedback",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column("question_id", sa.Integer(),
+ sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("user_id", sa.Integer(),
+ sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("message", sa.Text(), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False, server_default="open"),
+ sa.Column("reply", sa.Text(), nullable=True),
+ sa.Column("replied_by", sa.Integer(),
+ sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("replied_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
+ )
+ op.create_index("ix_question_feedback_question_id", "question_feedback", ["question_id"])
+ op.create_index("ix_question_feedback_status", "question_feedback", ["status"])
+
+
+def downgrade() -> None:
+ if "question_feedback" in sa.inspect(op.get_bind()).get_table_names():
+ op.drop_table("question_feedback")
diff --git a/backend/app/main.py b/backend/app/main.py
index 01bc441..b5cd3c2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -12,6 +12,7 @@ setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import access
+from app.routers import feedback
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
from app.utils.auth import get_password_hash
@@ -614,6 +615,7 @@ app.include_router(uploads.router)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
app.include_router(access.router, prefix="/api/access", tags=["access"])
+app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"])
app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"])
app.include_router(media.router, prefix="/api/media", tags=["media"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index ce0a2b9..d65f8ca 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -40,3 +40,5 @@ __all__ = [
"UserCollection",
"UserCollectionQuestion",
]
+
+from app.models.feedback import QuestionFeedback # noqa: F401
diff --git a/backend/app/models/feedback.py b/backend/app/models/feedback.py
new file mode 100644
index 0000000..2e2bda3
--- /dev/null
+++ b/backend/app/models/feedback.py
@@ -0,0 +1,30 @@
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
+
+from app.database import Base
+
+
+class QuestionFeedback(Base):
+ """A learner telling an educator something is wrong with a question.
+
+ It replaces the comment thread that used to sit under every question. A
+ discussion is public and needs moderating; this is a private report that
+ someone is expected to act on, which is what people were using comments for
+ anyway. It carries the question id because that is what an educator needs
+ to find the thing being reported.
+ """
+
+ __tablename__ = "question_feedback"
+
+ id = Column(Integer, primary_key=True, index=True)
+ question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+ message = Column(Text, nullable=False)
+ #: open | resolved. Kept rather than deleted on resolve, so a question with
+ #: a history of the same complaint is visibly that.
+ status = Column(String(20), nullable=False, default="open", index=True)
+ reply = Column(Text, nullable=True)
+ replied_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ replied_at = Column(DateTime, nullable=True)
+ created_at = Column(DateTime, default=datetime.utcnow, index=True)
diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py
index 0387620..9f94333 100644
--- a/backend/app/routers/attempts.py
+++ b/backend/app/routers/attempts.py
@@ -9,7 +9,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import case, func
-from app.services.attempt_expiry import active_key, progress_key, settle_if_expired
+from app.services.attempt_expiry import active_key, load_saved, progress_key, settle_if_expired
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
from app.database import get_db
@@ -768,6 +768,50 @@ def quiz_analysis(
}
+class _LiveAnswer:
+ """An answer that has been given but not yet submitted.
+
+ Shaped like an AttemptAnswer so the analysis does not have to care which of
+ the two it is reading. Not persisted: submitting is what writes answers,
+ and reading a page must not.
+ """
+
+ __slots__ = ("question_id", "user_answer", "is_correct", "seconds_spent")
+
+ def __init__(self, question_id, user_answer, is_correct):
+ self.question_id = question_id
+ self.user_answer = user_answer
+ self.is_correct = is_correct
+ # Per-question timing is submitted with the attempt, so a live session
+ # has none yet. None, not zero — those are different claims.
+ self.seconds_spent = None
+
+
+def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list:
+ """Grade what is saved for a live attempt, so it can be analysed mid-session."""
+ try:
+ import redis as redis_lib
+
+ from app.config import settings
+ r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
+ saved = load_saved(r, user_id, attempt.id)
+ except Exception:
+ logger.warning("Redis unavailable for live analysis of attempt %s", attempt.id, exc_info=True)
+ return []
+ answers = (saved or {}).get("answers") or {}
+ if not answers:
+ return []
+ graded = grade_quiz_answers(
+ get_quiz_questions(db, attempt.quiz_id),
+ [(int(qid), value) for qid, value in answers.items()],
+ attempt.selected_question_ids,
+ )
+ # Every question, not only the answered ones: the unanswered are what make
+ # the figure read "1 of 34" and the table show the rest as skipped, exactly
+ # as a finished session does.
+ return [_LiveAnswer(question.id, answer, correct) for question, answer, correct in graded]
+
+
@router.get("/{attempt_id}/analysis")
def attempt_analysis(
attempt_id: int,
@@ -788,6 +832,12 @@ def attempt_analysis(
quiz = db.get(Quiz, attempt.quiz_id)
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all()
+ # A session still in progress has no AttemptAnswer rows — they are written
+ # on submit — so the analysis of one read as entirely empty while the
+ # session list, which counts the saved progress, said a question had been
+ # answered. The two are now looking at the same thing.
+ if not rows and attempt.completed_at is None:
+ rows = _rows_from_progress(db, current_user.id, attempt)
question_ids = [row.question_id for row in rows]
questions = {q.id: q for q in db.query(Question).filter(Question.id.in_(question_ids)).all()} \
if question_ids else {}
diff --git a/backend/app/routers/feedback.py b/backend/app/routers/feedback.py
new file mode 100644
index 0000000..819670a
--- /dev/null
+++ b/backend/app/routers/feedback.py
@@ -0,0 +1,155 @@
+"""Feedback on a question: reported by a learner, worked through by an educator.
+
+The comment thread that used to sit under every question was a discussion
+nobody moderated. What it was actually used for was telling an educator
+something was wrong — a private report someone is meant to act on — so that is
+what this is.
+
+An educator sees what is outstanding, which question each report is about, and
+can reply, resolve or delete it. Resolving keeps the report: a question with a
+history of the same complaint should visibly have one.
+"""
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.feedback import QuestionFeedback
+from app.models.question import Question
+from app.models.user import User
+from app.utils.auth import get_current_user
+from app.utils.category_grants import is_question_manager, manageable_categories
+
+router = APIRouter()
+
+MAX_MESSAGE = 2000
+
+
+class FeedbackIn(BaseModel):
+ message: str = Field(min_length=3, max_length=MAX_MESSAGE)
+
+
+class ReplyIn(BaseModel):
+ reply: str | None = Field(default=None, max_length=MAX_MESSAGE)
+ status: str | None = None
+
+
+def _json(row: QuestionFeedback, senders: dict[int, User], questions: dict[int, Question]) -> dict:
+ sender = senders.get(row.user_id)
+ question = questions.get(row.question_id)
+ return {
+ "id": row.id,
+ "question_id": row.question_id,
+ # The stem, trimmed: an educator scanning a list needs to recognise the
+ # question, not read it.
+ "question_excerpt": (getattr(question, "question_text", "") or "")[:120],
+ "message": row.message,
+ "status": row.status,
+ "reply": row.reply,
+ "replied_at": row.replied_at,
+ "created_at": row.created_at,
+ "from_name": getattr(sender, "name", None) or "A learner",
+ "from_email": getattr(sender, "email", None),
+ }
+
+
+def _decorate(db: Session, rows: list[QuestionFeedback]) -> list[dict]:
+ if not rows:
+ return []
+ senders = {u.id: u for u in db.query(User).filter(
+ User.id.in_({r.user_id for r in rows if r.user_id}))} if any(r.user_id for r in rows) else {}
+ questions = {q.id: q for q in db.query(Question).filter(
+ Question.id.in_({r.question_id for r in rows}))}
+ return [_json(row, senders, questions) for row in rows]
+
+
+def _require_manager(db: Session, user: User) -> None:
+ if not is_question_manager(db, user):
+ raise HTTPException(403, "Reviewing feedback requires moderator access or a grant")
+
+
+def _visible(db: Session, user: User, query):
+ """Narrow to the questions this educator may manage. None means all."""
+ scope = manageable_categories(db, user)
+ if scope is None:
+ return query
+ if not scope:
+ return query.filter(QuestionFeedback.id.is_(None))
+ return query.join(Question, Question.id == QuestionFeedback.question_id).filter(
+ Question.question_category_id.in_(scope))
+
+
+@router.post("/questions/{question_id}", status_code=201)
+def leave_feedback(question_id: int, data: FeedbackIn, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """Report something about a question. Any signed-in learner may."""
+ if not db.query(Question.id).filter(Question.id == question_id).first():
+ raise HTTPException(404, "Question not found")
+ row = QuestionFeedback(question_id=question_id, user_id=current_user.id,
+ message=data.message.strip())
+ db.add(row)
+ db.commit()
+ return {"sent": True, "id": row.id}
+
+
+@router.get("/questions/{question_id}")
+def feedback_for_question(question_id: int, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """Every report about one question, for the educator editing it."""
+ _require_manager(db, current_user)
+ rows = (db.query(QuestionFeedback)
+ .filter(QuestionFeedback.question_id == question_id)
+ .order_by(QuestionFeedback.status.desc(), QuestionFeedback.created_at.desc())
+ .all())
+ return _decorate(db, rows)
+
+
+@router.get("/open")
+def open_feedback(limit: int = Query(8, ge=1, le=50), db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """What is outstanding — the count for a badge, and the newest few.
+
+ Answers quietly for someone with no editorial access rather than refusing,
+ so the header can ask for it without first asking who is asking.
+ """
+ if not is_question_manager(db, current_user):
+ return {"open": 0, "items": []}
+ base = _visible(db, current_user,
+ db.query(QuestionFeedback).filter(QuestionFeedback.status == "open"))
+ total = base.with_entities(func.count(QuestionFeedback.id)).scalar() or 0
+ rows = base.order_by(QuestionFeedback.created_at.desc()).limit(limit).all()
+ return {"open": int(total), "items": _decorate(db, rows)}
+
+
+@router.patch("/{feedback_id}")
+def answer_feedback(feedback_id: int, data: ReplyIn, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ """Reply to a report, resolve it, or reopen it."""
+ _require_manager(db, current_user)
+ row = db.get(QuestionFeedback, feedback_id)
+ if not row:
+ raise HTTPException(404, "Feedback not found")
+ if data.status is not None:
+ if data.status not in ("open", "resolved"):
+ raise HTTPException(400, "status must be open or resolved")
+ row.status = data.status
+ if data.reply is not None:
+ row.reply = data.reply.strip() or None
+ row.replied_by = current_user.id
+ row.replied_at = datetime.utcnow()
+ db.commit()
+ return _decorate(db, [row])[0]
+
+
+@router.delete("/{feedback_id}", status_code=204)
+def delete_feedback(feedback_id: int, db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)):
+ _require_manager(db, current_user)
+ row = db.get(QuestionFeedback, feedback_id)
+ if not row:
+ raise HTTPException(404, "Feedback not found")
+ db.delete(row)
+ db.commit()
diff --git a/backend/tests/test_feedback.py b/backend/tests/test_feedback.py
new file mode 100644
index 0000000..0b3523e
--- /dev/null
+++ b/backend/tests/test_feedback.py
@@ -0,0 +1,114 @@
+"""Feedback on a question: reported by a learner, worked through by an educator.
+
+Disposable SQLite. The rules worth pinning: any learner may report, only
+someone with editorial access may read or answer, resolving keeps the report,
+and the badge answers quietly for someone with no access rather than refusing.
+"""
+import unittest
+
+import test_quiz_builder as fixtures
+from app.models.category_grant import CategoryGrant
+from app.models.feedback import QuestionFeedback
+from app.routers import feedback
+
+
+class FeedbackTests(unittest.TestCase):
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.db = self.bank.db
+ self.client.app.include_router(feedback.router, prefix="/feedback")
+ self.bank.user = self.bank.owner
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def send(self, question_id=1, message="The answer key looks wrong."):
+ return self.client.post(f"/feedback/questions/{question_id}", json={"message": message})
+
+ def test_any_learner_may_report_and_it_names_the_question(self):
+ self.bank.user = self.bank.peer
+ response = self.send()
+ self.assertEqual(response.status_code, 201, response.text)
+ row = self.db.query(QuestionFeedback).one()
+ self.assertEqual((row.question_id, row.user_id, row.status), (1, self.bank.peer.id, "open"))
+
+ def test_a_report_about_nothing_is_refused(self):
+ self.assertEqual(self.send(question_id=9999).status_code, 404)
+ self.assertEqual(self.client.post("/feedback/questions/1", json={"message": "no"}).status_code, 422)
+
+ def test_only_an_educator_may_read_or_answer(self):
+ self.send()
+ row = self.db.query(QuestionFeedback).one()
+ self.bank.user = self.bank.peer
+ self.assertEqual(self.client.get("/feedback/questions/1").status_code, 403)
+ self.assertEqual(self.client.patch(f"/feedback/{row.id}", json={"status": "resolved"}).status_code, 403)
+ self.assertEqual(self.client.delete(f"/feedback/{row.id}").status_code, 403)
+
+ def test_the_badge_answers_quietly_for_someone_with_no_access(self):
+ self.send()
+ self.bank.user = self.bank.peer
+ body = self.client.get("/feedback/open").json()
+ # Not a 403: the header asks for this without first asking who is asking.
+ self.assertEqual(body, {"open": 0, "items": []})
+
+ def test_an_educator_sees_what_is_outstanding_with_the_question_it_is_about(self):
+ self.send()
+ self.bank.user = self.bank.mod
+ body = self.client.get("/feedback/open").json()
+ self.assertEqual(body["open"], 1)
+ self.assertEqual(body["items"][0]["question_id"], 1)
+ self.assertTrue(body["items"][0]["question_excerpt"])
+ self.assertEqual(body["items"][0]["from_name"], self.bank.owner.name)
+
+ def test_replying_resolves_and_keeps_the_report(self):
+ self.send()
+ row_id = self.db.query(QuestionFeedback).one().id
+ self.bank.user = self.bank.mod
+ answered = self.client.patch(f"/feedback/{row_id}",
+ json={"reply": "Fixed, thank you.", "status": "resolved"})
+ self.assertEqual(answered.status_code, 200, answered.text)
+ self.assertEqual(answered.json()["reply"], "Fixed, thank you.")
+ # Kept, not deleted: a question with a history of the same complaint
+ # should visibly have one.
+ self.db.expire_all()
+ self.assertEqual(self.db.query(QuestionFeedback).count(), 1)
+ self.assertEqual(self.client.get("/feedback/open").json()["open"], 0)
+
+ def test_a_resolved_report_can_be_reopened(self):
+ self.send()
+ row_id = self.db.query(QuestionFeedback).one().id
+ self.bank.user = self.bank.mod
+ self.client.patch(f"/feedback/{row_id}", json={"status": "resolved"})
+ self.client.patch(f"/feedback/{row_id}", json={"status": "open"})
+ self.assertEqual(self.client.get("/feedback/open").json()["open"], 1)
+ self.assertEqual(self.client.patch(f"/feedback/{row_id}", json={"status": "maybe"}).status_code, 400)
+
+ def test_deleting_removes_it(self):
+ self.send()
+ row_id = self.db.query(QuestionFeedback).one().id
+ self.bank.user = self.bank.mod
+ self.assertEqual(self.client.delete(f"/feedback/{row_id}").status_code, 204)
+ self.assertEqual(self.db.query(QuestionFeedback).count(), 0)
+
+ def test_a_granted_educator_sees_only_their_own_branch(self):
+ # Questions 1 and 2 sit under the Root branch; 6 is filed nowhere, so
+ # no grant over the tree can reach it.
+ self.send(question_id=1)
+ self.send(question_id=6)
+ self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
+ self.db.commit()
+
+ self.bank.user = self.bank.peer
+ body = self.client.get("/feedback/open").json()
+ self.assertEqual(body["open"], 1)
+ self.assertEqual(body["items"][0]["question_id"], 1)
+
+ # A moderator sees both, including the one filed nowhere.
+ self.bank.user = self.bank.mod
+ self.assertEqual(self.client.get("/feedback/open").json()["open"], 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py
index 8ec08a1..461fdb7 100644
--- a/backend/tests/test_session_lifecycle.py
+++ b/backend/tests/test_session_lifecycle.py
@@ -233,3 +233,48 @@ class QuizAnalysisTests(unittest.TestCase):
quiz_id = self.bank.generate(is_shared=False, category_ids=[1], count=2).json()["id"]
self.bank.user = self.bank.peer
self.assertEqual(self.client.get(f"/attempts/quiz/{quiz_id}/analysis").status_code, 403)
+
+
+class LiveAnalysisTests(unittest.TestCase):
+ """A session can be analysed while it is still being sat."""
+
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.db = self.bank.db
+ self.client.app.include_router(attempts.router, prefix="/attempts")
+ self.bank.user = self.bank.owner
+ self.store = {}
+ self.redis = fake_redis(self.store)
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def test_answers_given_but_not_submitted_are_analysed(self):
+ quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
+ with patch.dict(sys.modules, {"redis": self.redis}):
+ aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"]
+ # One answered, saved to progress; nothing submitted.
+ self.store[f"quiz_progress:{self.bank.owner.id}:{aid}"] = json.dumps({"answers": {"1": "yes"}})
+
+ with patch.dict(sys.modules, {"redis": self.redis}):
+ body = self.client.get(f"/attempts/{aid}/analysis").json()
+
+ # It used to report 0/0 with an empty table while the session list,
+ # which reads the same saved progress, said one had been answered.
+ self.assertEqual(body["answered"], 1)
+ self.assertGreater(body["total"], 1)
+ self.assertEqual(body["score"], 1)
+ statuses = {q["status"] for q in body["questions"]}
+ self.assertIn("correct", statuses)
+ self.assertIn("skipped", statuses)
+ # Nothing was written: submitting is what records answers.
+ self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 0)
+
+ def test_a_live_attempt_with_nothing_answered_reads_as_nothing_answered(self):
+ quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
+ with patch.dict(sys.modules, {"redis": self.redis}):
+ aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}").json()["id"]
+ body = self.client.get(f"/attempts/{aid}/analysis").json()
+ self.assertEqual((body["answered"], body["score"]), (0, 0))
diff --git a/frontend/src/components/CommentSection.jsx b/frontend/src/components/CommentSection.jsx
deleted file mode 100644
index 2143cb8..0000000
--- a/frontend/src/components/CommentSection.jsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import api from '../api/client'
-
-const LIMIT = 20
-
-export default function CommentSection({ articleId, questionId }) {
- const [comments, setComments] = useState([])
- const [total, setTotal] = useState(0)
- const [offset, setOffset] = useState(0)
- const [draft, setDraft] = useState('')
- const [error, setError] = useState('')
- const [submitting, setSubmitting] = useState(false)
- const [loaded, setLoaded] = useState(false)
-
- const params = articleId ? { article_id: articleId } : { question_id: questionId }
-
- const load = useCallback(async (off = 0) => {
- try {
- const res = await api.get('/comments/', { params: { ...params, limit: LIMIT, offset: off } })
- const list = res.data?.comments || []
- setComments(prev => off === 0 ? list : [...prev, ...list])
- setTotal(res.data?.total || 0)
- setOffset(off)
- } catch { setComments([]); setTotal(0) }
- finally { setLoaded(true) }
- }, [articleId, questionId])
-
- useEffect(() => { setComments([]); setLoaded(false); load(0) }, [load])
-
- const submit = async () => {
- setError('')
- setSubmitting(true)
- try {
- const res = await api.post('/comments/', { ...params, content: draft })
- setComments(prev => [res.data, ...prev.filter(c => c.id !== res.data.id)])
- setTotal(t => t + 1)
- setDraft('')
- } catch (err) {
- setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not post comment')
- } finally { setSubmitting(false) }
- }
-
- const moderate = async (id, status) => {
- setError('')
- try {
- const res = await api.patch(`/comments/${id}`, { status })
- setComments(prev => prev.map(c => c.id === id ? res.data : c))
- } catch { setError('Could not moderate comment') }
- }
-
- if (!loaded) return null
-
- return (
-
-
-
Discussion{total > 0 ? ` · ${total}` : ''}
- Visible to everyone after educator approval.
-
-
- {comments.length === 0 ? (
- No comments yet — start the discussion.
- ) : (
-
- )}
- {comments.length < total && (
- load(offset + LIMIT)}>Load more
- )}
-
- )
-}
diff --git a/frontend/src/components/CommentSection.test.jsx b/frontend/src/components/CommentSection.test.jsx
deleted file mode 100644
index ee544d7..0000000
--- a/frontend/src/components/CommentSection.test.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import CommentSection from './CommentSection'
-import api from '../api/client'
-
-vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
-
-const comments = [
- { id: 1, author_name: 'Educator', content: 'Approved **note**', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false },
- { id: 2, author_name: 'Me', content: 'My pending note', status: 'pending', created_at: '2026-09-07T11:00:00', own: true, can_moderate: false },
-]
-
-beforeEach(() => {
- vi.resetAllMocks()
- api.get.mockResolvedValue({ data: { total: comments.length, comments } })
- api.post.mockResolvedValue({ data: { id: 3, author_name: 'Me', content: 'New note', status: 'pending', created_at: '2026-09-07T12:00:00', own: true, can_moderate: false } })
- api.patch.mockResolvedValue({ data: { ...comments[1], status: 'approved', can_moderate: true } })
-})
-
-describe('comment section', () => {
- it('lists comments with markdown and own pending state', async () => {
- render( )
- expect(await screen.findByText('Educator')).toBeInTheDocument()
- expect(screen.getByText('note')).toBeInTheDocument() // **note** rendered as strong text
- expect(screen.getByText(/awaiting approval/)).toBeInTheDocument()
- })
-
- it('posts a comment and prepends it', async () => {
- render( )
- await screen.findByRole('heading', { name: /Discussion/ })
- await userEvent.type(screen.getByLabelText('Comment text'), 'New note')
- await userEvent.click(screen.getByRole('button', { name: 'Post comment' }))
- await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments/', { question_id: 5, content: 'New note' }))
- expect(await screen.findByText('New note')).toBeInTheDocument()
- })
-
- it('renders comment markdown without executing raw HTML', async () => {
- api.get.mockResolvedValue({ data: { total: 1, comments: [{ id: 4, author_name: 'A', content: ' Text', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false }] } })
- const { container } = render( )
- expect(await screen.findByText(/Text/)).toBeInTheDocument()
- expect(container.querySelector('img')).toBeNull()
- })
-
- it('lets a moderator approve and reject', async () => {
- api.get.mockResolvedValue({ data: { total: 1, comments: [{ ...comments[1], can_moderate: true }] } })
- render( )
- expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument()
- await userEvent.click(screen.getByRole('button', { name: 'Approve' }))
- await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/comments/2', { status: 'approved' }))
- })
-})
diff --git a/frontend/src/components/Feedback.css b/frontend/src/components/Feedback.css
new file mode 100644
index 0000000..a076b2d
--- /dev/null
+++ b/frontend/src/components/Feedback.css
@@ -0,0 +1,38 @@
+/* Reporting a question, and working through what was reported. */
+
+.fb-form { display: flex; flex-direction: column; gap: 8px; min-width: 240px; }
+.fb-form label { font-size: 0.8rem; font-weight: 650; }
+.fb-form textarea {
+ width: 100%; padding: 9px 11px; font-family: inherit;
+ /* 16px on touch so iOS does not zoom the page in on focus. */
+ font-size: 16px; resize: vertical;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text);
+}
+@media (min-width: 700px) { .fb-form textarea { font-size: 0.86rem; } }
+.fb-form-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.fb-qid { font-size: 0.74rem; color: var(--text-subtle); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+.fb-sent { margin: 0; font-size: 0.84rem; color: var(--correct-fg); }
+.fb-error { margin: 6px 0 0; font-size: 0.82rem; color: var(--wrong-fg); }
+.fb-none { margin: 0; font-size: 0.84rem; color: var(--text-muted); }
+
+.fb-count { margin: 0 0 10px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--wrong-fg); }
+.fb-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
+.fb-item { padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; }
+.fb-item.is-open { border-left: 3px solid var(--wrong-fg); }
+.fb-item.is-resolved { opacity: 0.78; }
+.fb-item-head { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; margin-bottom: 6px; font-size: 0.78rem; color: var(--text-muted); }
+.fb-item-head strong { font-size: 0.84rem; color: var(--text); }
+.fb-tag { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 7px; border-radius: 10px; }
+.fb-tag.is-open { background: var(--wrong-bg); color: var(--wrong-fg); }
+.fb-tag.is-resolved { background: var(--option-sel-bg); color: var(--primary); }
+.fb-message { margin: 0 0 10px; font-size: 0.87rem; line-height: 1.6; overflow-wrap: anywhere; }
+.fb-reply { margin: 0 0 10px; padding: 9px 11px; font-size: 0.84rem; line-height: 1.6; background: var(--card-bg); border-radius: 8px; }
+.fb-reply-form { display: flex; flex-direction: column; gap: 8px; }
+.fb-reply-form textarea {
+ width: 100%; padding: 9px 11px; font-family: inherit; font-size: 16px; resize: vertical;
+ border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
+}
+@media (min-width: 700px) { .fb-reply-form textarea { font-size: 0.86rem; } }
+.fb-actions { display: flex; gap: 6px; flex-wrap: wrap; }
+.fb-delete { color: var(--wrong-fg); border-color: var(--wrong-bd); }
diff --git a/frontend/src/components/FeedbackForm.jsx b/frontend/src/components/FeedbackForm.jsx
new file mode 100644
index 0000000..b1b2ab4
--- /dev/null
+++ b/frontend/src/components/FeedbackForm.jsx
@@ -0,0 +1,59 @@
+import { useState } from 'react'
+import api from '../api/client'
+
+/**
+ * Telling an educator something is wrong with a question.
+ *
+ * It replaces the comment thread that used to sit under every question. A
+ * discussion is public and needs moderating; this is a private report someone
+ * is expected to act on, which is what the comments were being used for.
+ *
+ * The question's id is shown, because that is what an educator will search for
+ * and what the reply will come back about.
+ */
+export default function FeedbackForm({ questionId, onDone }) {
+ const [message, setMessage] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [sent, setSent] = useState(false)
+ const [error, setError] = useState('')
+
+ const send = async () => {
+ setBusy(true); setError('')
+ try {
+ await api.post(`/feedback/questions/${questionId}`, { message: message.trim() })
+ setSent(true)
+ setMessage('')
+ onDone?.()
+ } catch (err) {
+ const detail = err?.response?.data?.detail
+ setError(typeof detail === 'string' ? detail : 'Could not send that')
+ } finally { setBusy(false) }
+ }
+
+ if (sent) {
+ return (
+
+ Sent. An educator will see it against question #{questionId}.
+
+ )
+ }
+
+ return (
+
+
+ What is wrong with this question?
+
+
+ )
+}
diff --git a/frontend/src/components/FeedbackPanel.jsx b/frontend/src/components/FeedbackPanel.jsx
new file mode 100644
index 0000000..7ace270
--- /dev/null
+++ b/frontend/src/components/FeedbackPanel.jsx
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useState } from 'react'
+import api from '../api/client'
+import './Feedback.css'
+
+const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
+ { day: '2-digit', month: 'short', year: 'numeric' }) : '')
+
+/**
+ * What learners have reported about this question, for the educator editing it.
+ *
+ * It sits on the edit page rather than in a queue of its own, because the
+ * answer to almost every report is a change to the question — and having the
+ * report beside the field it is about is the whole point.
+ *
+ * Resolving keeps the report. A question with a history of the same complaint
+ * should visibly have one; deleting is for the ones that were never about the
+ * question.
+ */
+export default function FeedbackPanel({ questionId }) {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [replyTo, setReplyTo] = useState(null)
+ const [draft, setDraft] = useState('')
+
+ const load = useCallback(() => {
+ if (!questionId) { setLoading(false); return }
+ api.get(`/feedback/questions/${questionId}`)
+ .then(res => setItems(res.data || []))
+ .catch(() => setItems([]))
+ .finally(() => setLoading(false))
+ }, [questionId])
+
+ useEffect(() => { load() }, [load])
+
+ const run = async (fn, failure) => {
+ setBusy(true); setError('')
+ try { await fn(); load() }
+ catch { setError(failure) }
+ finally { setBusy(false) }
+ }
+
+ const setStatus = (row, status) => run(
+ () => api.patch(`/feedback/${row.id}`, { status }), 'Could not change that')
+
+ const sendReply = (row) => run(
+ () => api.patch(`/feedback/${row.id}`, { reply: draft.trim(), status: 'resolved' })
+ .then(res => { setReplyTo(null); setDraft(''); return res }),
+ 'Could not send that reply')
+
+ const remove = (row) => run(
+ () => api.delete(`/feedback/${row.id}`), 'Could not delete that')
+
+ if (!questionId) return null
+ if (loading) return null
+ if (items.length === 0) {
+ return No feedback on this question.
+ }
+
+ const open = items.filter(row => row.status === 'open').length
+
+ return (
+
+ {error &&
{error}
}
+ {open > 0 &&
{open} open
}
+
+
+ {items.map(row => (
+
+
+ {row.from_name}
+ {when(row.created_at)}
+ {row.status}
+
+ {row.message}
+
+ {row.reply && (
+
+ Replied: {row.reply}
+
+ )}
+
+ {replyTo === row.id ? (
+
+ ) : (
+
+ { setReplyTo(row.id); setDraft(row.reply || '') }}>
+ Reply
+
+ {row.status === 'open' ? (
+ setStatus(row, 'resolved')}>Resolve
+ ) : (
+ setStatus(row, 'open')}>Reopen
+ )}
+ remove(row)}>Delete
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/FigureManager.css b/frontend/src/components/FigureManager.css
index 6e542b7..dccbc5c 100644
--- a/frontend/src/components/FigureManager.css
+++ b/frontend/src/components/FigureManager.css
@@ -55,3 +55,8 @@
.fm-actions { width: 100%; }
.fm-actions .btn { flex: 1; }
}
+
+/* The add button and whatever follows it are separate actions, not a stack of
+ touching pills — they were adjacent inline elements with no margin between. */
+.fm-role > .btn { display: inline-flex; margin-top: 2px; }
+.fm + .btn { display: inline-flex; margin-top: 14px; }
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 7a6b561..497f45c 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -7,6 +7,70 @@ import ExamSwitcher from './ExamSwitcher'
import GlobalSearch from './GlobalSearch'
import useHidingBar from '../hooks/useHidingBar'
+/**
+ * What learners have reported and nobody has dealt with yet.
+ *
+ * Each row names the question it is about, because that is what an educator
+ * searches for, and opens straight into that question's editor — where the
+ * report sits beside the field it is about.
+ *
+ * The endpoint answers quietly with zero for someone with no editorial access,
+ * so this can ask without first working out who is asking.
+ */
+function FeedbackBadge() {
+ const [data, setData] = useState({ open: 0, items: [] })
+ const [open, setOpen] = useState(false)
+ const wrap = useRef(null)
+
+ useEffect(() => {
+ let live = true
+ const load = () => api.get('/feedback/open')
+ .then(res => { if (live) setData(res.data || { open: 0, items: [] }) })
+ .catch(() => {})
+ load()
+ const timer = setInterval(load, 60000)
+ return () => { live = false; clearInterval(timer) }
+ }, [])
+
+ useEffect(() => {
+ if (!open) return undefined
+ const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) }
+ document.addEventListener('mousedown', away)
+ return () => document.removeEventListener('mousedown', away)
+ }, [open])
+
+ if (!data.open) return null
+
+ return (
+
+
setOpen(v => !v)}>
+ ⚑ {data.open}
+
+ {open && (
+
+
Open feedback
+
+ {data.items.map(item => (
+
+ setOpen(false)}>
+ #{item.question_id}
+ {item.question_excerpt}…
+ {item.message}
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+
function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false)
const allJobs = jobs
@@ -163,8 +227,8 @@ export default function Navbar({ onSignIn, onRegister }) {
// One entry. Sessions and analysis are the same subject — the list of what
// you have sat and the reading of how it went — so they are one page.
{ to: '/sessions', label: 'Sessions' },
- { to: '/question-bank', label: 'Question Bank' },
- ...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
+ { to: '/question-bank', label: 'Qbank' },
+ ...(canManageQuestions ? [{ to: '/questions/manage', label: 'Questions' },
{ to: '/media', label: 'Images' },
{ to: '/editorial', label: 'Editorial' }] : []),
{ to: '/study-plans', label: 'Study plans' },
@@ -184,6 +248,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{user ? (
+
{
currentUser = { id: 2, name: 'Mod', role: 'moderator' }
mount()
expect(await screen.findByRole('link', { name: 'Images' })).toBeInTheDocument()
- expect(screen.getAllByRole('link', { name: 'Manage Qs' })[0]).toBeInTheDocument()
+ expect(screen.getAllByRole('link', { name: 'Questions' })[0]).toBeInTheDocument()
})
it('keeps what is about you out of the section bar', async () => {
diff --git a/frontend/src/components/QuestionImport.css b/frontend/src/components/QuestionImport.css
new file mode 100644
index 0000000..6b069d3
--- /dev/null
+++ b/frontend/src/components/QuestionImport.css
@@ -0,0 +1,33 @@
+/* Bringing questions in from a spreadsheet or a QTI export. */
+
+.qi-overlay {
+ position: fixed; inset: 0; z-index: 1100; display: flex;
+ align-items: center; justify-content: center; padding: 16px;
+ background: rgba(15, 23, 42, 0.5);
+}
+.qi {
+ display: flex; flex-direction: column;
+ width: min(560px, 100%); max-height: 86vh;
+ background: var(--card-bg); border-radius: 14px;
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
+}
+.qi-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px 0; }
+.qi-head h2 { margin: 0; font-size: 1.1rem; font-weight: 700; }
+.qi-head button { width: 32px; height: 32px; border: 0; border-radius: 50%; background: none; cursor: pointer; color: var(--text-muted); }
+.qi-head button:hover { background: var(--bg); color: var(--text); }
+
+.qi-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 22px; }
+.qi-body section + section { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--border); }
+.qi-body h3 { margin: 0 0 4px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); }
+.qi-body p { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-muted); }
+.qi-body input[type="file"] { display: block; margin-bottom: 10px; font-size: 0.85rem; max-width: 100%; }
+
+.qi-result { margin-top: 10px; padding: 9px 12px; font-size: 0.85rem; background: var(--bg); border-radius: 8px; }
+/* Every failed row, not a count: an import that quietly drops a third of a
+ file is worse than one that refuses. */
+.qi-errors { margin: 8px 0 0; padding-left: 18px; font-size: 0.8rem; color: var(--wrong-fg); }
+.qi-error { margin-top: 10px; font-size: 0.85rem; color: var(--wrong-fg); }
+.qi-foot { display: flex; justify-content: flex-end; padding: 14px 22px calc(18px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
+
+.qi-sample { font-size: 0.82rem; }
+.qi-sample a { color: var(--primary); }
diff --git a/frontend/src/components/QuestionImport.jsx b/frontend/src/components/QuestionImport.jsx
new file mode 100644
index 0000000..8f17be7
--- /dev/null
+++ b/frontend/src/components/QuestionImport.jsx
@@ -0,0 +1,152 @@
+import { useRef, useState } from 'react'
+import api from '../api/client'
+import './QuestionImport.css'
+
+const apiError = (err, fallback) => {
+ const detail = err?.response?.data?.detail
+ return typeof detail === 'string' ? detail : fallback
+}
+
+/**
+ * Bringing questions in from a spreadsheet or a QTI export.
+ *
+ * It lived on the question bank, which is now a page about starting a session
+ * rather than about the question bank's contents. Importing is management, so
+ * it belongs with the rest of it.
+ *
+ * The result is reported in full — how many of how many, and every row that
+ * failed — because an import that silently drops a third of a file is worse
+ * than one that refuses.
+ */
+export default function QuestionImport({ onImported, selectedIds }) {
+ const [open, setOpen] = useState(false)
+ const [file, setFile] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [result, setResult] = useState(null)
+ const [qtiBusy, setQtiBusy] = useState(false)
+ const [qtiResult, setQtiResult] = useState('')
+ const [exporting, setExporting] = useState(false)
+ const qtiInput = useRef(null)
+
+ const importSheet = async () => {
+ if (!file) return
+ setBusy(true); setResult(null)
+ try {
+ const body = new FormData()
+ body.append('file', file)
+ const res = await api.post('/questions/import/upload', body)
+ setResult(res.data)
+ if (res.data.imported > 0) onImported?.()
+ } catch (err) {
+ setResult({ error: apiError(err, 'Import failed') })
+ } finally { setBusy(false) }
+ }
+
+ const importQti = async (event) => {
+ const chosen = event.target.files?.[0]
+ event.target.value = ''
+ if (!chosen) return
+ setQtiBusy(true); setQtiResult('')
+ try {
+ const body = new FormData()
+ body.append('file', chosen)
+ const res = await api.post('/questions/import/qti', body)
+ const failed = res.data.errors?.length ? ` ${res.data.errors.length} row(s) failed.` : ''
+ setQtiResult(`Imported ${res.data.imported} of ${res.data.total_items} questions.${failed}`)
+ if (res.data.imported > 0) onImported?.()
+ } catch (err) {
+ setQtiResult(apiError(err, 'QTI import failed'))
+ } finally { setQtiBusy(false) }
+ }
+
+ const chosen = selectedIds ? [...selectedIds] : []
+
+ const exportQti = async () => {
+ setExporting(true); setQtiResult('')
+ try {
+ const query = chosen.length ? `?question_ids=${chosen.join(',')}` : ''
+ const res = await api.get(`/questions/export/qti${query}`, { responseType: 'blob' })
+ const url = URL.createObjectURL(res.data)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = 'questions_qti.xml'
+ link.click()
+ URL.revokeObjectURL(url)
+ } catch (err) {
+ setQtiResult(apiError(err, 'QTI export failed'))
+ } finally { setExporting(false) }
+ }
+
+ return (
+ <>
+ setOpen(true)}>Import / export
+
+ {open && (
+ e.target === e.currentTarget && setOpen(false)}>
+
+
+
Import and export
+ setOpen(false)} aria-label="Close">✕
+
+
+
+
+ Spreadsheet
+ A CSV or Excel file, one question per row.
+
+ Download a sample file to see the columns.
+
+ { setFile(e.target.files?.[0] || null); setResult(null) }} />
+
+ {busy ? 'Importing…' : 'Import spreadsheet'}
+
+
+ {result && !result.error && (
+
+ Imported
{result.imported} of {result.total_rows} rows.
+ {result.errors?.length > 0 && (
+
+ {result.errors.map((message, index) => {message} )}
+
+ )}
+
+ )}
+ {result?.error && {result.error}
}
+
+
+
+
+
+ Export
+
+ {chosen.length
+ ? `The ${chosen.length} question${chosen.length === 1 ? '' : 's'} you have selected, as QTI XML.`
+ : 'Every question you can see, as QTI XML. Select some first to export only those.'}
+
+
+ {exporting ? 'Exporting…' : `Export QTI${chosen.length ? ` (${chosen.length})` : ''}`}
+
+
+
+
+
+ setOpen(false)}>Done
+
+
+
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/QuestionImport.test.jsx b/frontend/src/components/QuestionImport.test.jsx
new file mode 100644
index 0000000..19cf3bf
--- /dev/null
+++ b/frontend/src/components/QuestionImport.test.jsx
@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import QuestionImport from './QuestionImport'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
+
+const open = async (props = {}) => {
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: 'Import / export' }))
+}
+
+describe('importing and exporting questions', () => {
+ beforeEach(() => { vi.clearAllMocks() })
+
+ it('reports every failed row, not just a count', async () => {
+ await open()
+ api.post.mockResolvedValue({ data: { imported: 8, total_rows: 10, errors: ['Row 3: no answer', 'Row 7: no stem'] } })
+ const file = new File(['a,b'], 'q.csv', { type: 'text/csv' })
+ await userEvent.upload(screen.getByLabelText('Spreadsheet to import'), file)
+ await userEvent.click(screen.getByRole('button', { name: 'Import spreadsheet' }))
+
+ expect(await screen.findByText(/Imported/)).toHaveTextContent('Imported 8 of 10 rows.')
+ // An import that quietly drops two rows is worse than one that refuses.
+ expect(screen.getByText('Row 3: no answer')).toBeInTheDocument()
+ expect(screen.getByText('Row 7: no stem')).toBeInTheDocument()
+ })
+
+ it('surfaces a refusal rather than doing nothing', async () => {
+ await open()
+ api.post.mockRejectedValue({ response: { data: { detail: 'Unsupported file' } } })
+ await userEvent.upload(screen.getByLabelText('Spreadsheet to import'),
+ new File(['x'], 'q.csv', { type: 'text/csv' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Import spreadsheet' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unsupported file')
+ })
+
+ it('exports everything when nothing is selected', async () => {
+ await open()
+ api.get.mockResolvedValue({ data: new Blob([' ']) })
+ global.URL.createObjectURL = vi.fn(() => 'blob:x')
+ global.URL.revokeObjectURL = vi.fn()
+ await userEvent.click(screen.getByRole('button', { name: 'Export QTI' }))
+ expect(api.get).toHaveBeenCalledWith('/questions/export/qti', { responseType: 'blob' })
+ })
+
+ it('exports only the selection when there is one', async () => {
+ await open({ selectedIds: new Set([4, 9]) })
+ api.get.mockResolvedValue({ data: new Blob([' ']) })
+ global.URL.createObjectURL = vi.fn(() => 'blob:x')
+ global.URL.revokeObjectURL = vi.fn()
+ expect(screen.getByText(/The 2 questions you have selected/)).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Export QTI (2)' }))
+ expect(api.get).toHaveBeenCalledWith('/questions/export/qti?question_ids=4,9', { responseType: 'blob' })
+ })
+
+ it('keeps the sample file within reach, since the columns are not guessable', async () => {
+ await open()
+ expect(screen.getByRole('link', { name: 'Download a sample file' }))
+ .toHaveAttribute('href', '/api/questions/import/sample')
+ })
+})
diff --git a/frontend/src/components/QuestionPreview.jsx b/frontend/src/components/QuestionPreview.jsx
new file mode 100644
index 0000000..8cf7ab9
--- /dev/null
+++ b/frontend/src/components/QuestionPreview.jsx
@@ -0,0 +1,135 @@
+import { Suspense, lazy } from 'react'
+import { Link } from 'react-router-dom'
+import RichText from './RichText'
+import QuestionReadingLinks from './QuestionReadingLinks'
+import { uploadUrl } from '../utils/uploads'
+
+const TeachChat = lazy(() => import('./TeachChat'))
+
+/**
+ * One question, shown whole, without leaving the page you found it on.
+ *
+ * It is a preview, not practice: the correct option, the per-option reasoning,
+ * the explanation and the key points are all shown at once. Making someone
+ * answer first is the right shape for a quiz and the wrong one for a list of
+ * questions being inspected.
+ *
+ * Favourites and personal folders are optional and off by default — those
+ * belong to studying a question, not to looking through the bank.
+ */
+export default function QuestionPreview({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
+ return (
+ <>
+ e.target === e.currentTarget && onClose()}>
+
+
+
+ {question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
+
+
+ {onToggleFavorite && (
+ onToggleFavorite(question.id)}
+ title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.4rem', padding: 2, lineHeight: 1 }}>
+ {isFavorited ? '⭐' : '☆'}
+
+ )}
+ ✕
+
+
+ {/* Markdown, not injected HTML: a stem is educator prose, and the
+ same renderer the quiz player uses keeps a lab table a table. */}
+
+
+
+ {question.image_path && (
+
+
e.target.style.display = 'none'} />
+
+ )}
+ {question.options && (
+
+ {question.options.map((opt, i) => {
+ const isCorrectOpt = opt === question.correct_answer
+ return (
+
+ {String.fromCharCode(65 + i)}
+ {opt}
+ {isCorrectOpt && ✓ Correct }
+ {question.option_explanations?.[opt] && (
+
+
+
+ )}
+
+ )
+ })}
+
+ )}
+ {(question.explanation || question.explanation_image_path) && (
+
+
Explanation:
+ {question.explanation &&
}
+ {question.explanation_image_path && (
+
+
e.currentTarget.style.display = 'none'} />
+
+ )}
+
+ )}
+ {(question.key_points || []).length > 0 && (
+
+
Key points
+
+ {question.key_points.map((point, i) => (
+
+ {point.text}
+ {point.article_id && (
+ 📖 Read more
+ )}
+
+ ))}
+
+
+ )}
+
+
+
Add to library
+
+ {
+ if (!e.target.value) return
+ await api.put(`/collections/${e.target.value}/questions/${question.id}`)
+ e.target.value = ''
+ }} aria-label="Add to collection">
+ Choose collection…
+ {collections.map(c => {c.title} )}
+
+ {
+ if (e.key === 'Enter' && e.target.value.trim()) {
+ const res = await api.post('/collections/', { title: e.target.value.trim() })
+ await api.put(`/collections/${res.data.id}/questions/${question.id}`)
+ setCollections(prev => [...prev, res.data])
+ e.target.value = ''
+ }
+ }} style={{ padding: '4px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem' }} />
+
+
+
+ Close
+
+
+
+ {/* AI Tutor — z-index above the modal */}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/components/SiteFooter.css b/frontend/src/components/SiteFooter.css
index 7817f1c..6781bea 100644
--- a/frontend/src/components/SiteFooter.css
+++ b/frontend/src/components/SiteFooter.css
@@ -1,10 +1,11 @@
/* A footer you can navigate from. */
.site-footer {
- /* Enough to separate it from the page, not enough to look like the page
- ended early. */
- margin-top: 32px;
- padding: 30px 0 calc(28px + env(safe-area-inset-bottom));
+ /* No top margin: a margin here is a band of page background between the
+ content and the footer, which reads as the page having ended early and a
+ stray strip left behind. The rule and the padding do the separating. */
+ margin-top: 0;
+ padding: 36px 0 calc(28px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--card-bg);
text-align: left;
diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx
index 2e858b5..d3fb007 100644
--- a/frontend/src/components/SiteFooter.jsx
+++ b/frontend/src/components/SiteFooter.jsx
@@ -12,7 +12,7 @@ const COLUMNS = [
heading: 'Study',
links: [
{ to: '/', label: 'Dashboard' },
- { to: '/question-bank', label: 'Question bank' },
+ { to: '/question-bank', label: 'Qbank' },
{ to: '/study-plans', label: 'Study plans' },
{ to: '/sessions', label: 'Sessions' },
],
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 38ddff7..d848659 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -871,3 +871,30 @@ html, body { overflow-x: hidden; max-width: 100%; }
}
.navbar .acct-menu a:hover, .acct-menu button:hover { background: var(--bg) !important; }
.acct-menu .acct-signout { color: var(--wrong-fg) !important; border-top: 1px solid var(--border) !important; border-radius: 0 0 7px 7px; margin-top: 4px; }
+
+/* ── Open feedback ──────────────────────────────────────────────
+ What learners have reported and nobody has dealt with yet. Each row names
+ the question, because that is what an educator searches for. */
+.fbadge { position: relative; }
+.fbadge-button {
+ background: var(--wrong-fg) !important; color: #fff !important;
+ border: 0 !important; border-radius: 20px; padding: 3px 11px;
+ font-size: 0.75rem; font-weight: 700; cursor: pointer; white-space: nowrap;
+}
+.fbadge-menu {
+ position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
+ width: min(360px, 84vw); max-height: 60vh; overflow-y: auto; padding: 6px;
+ background: var(--card-bg); color: var(--text);
+ border: 1px solid var(--border); border-radius: 10px;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
+}
+.fbadge-head { padding: 8px 10px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
+.fbadge-menu ul { list-style: none; margin: 0; padding: 0; }
+.navbar .fbadge-menu a {
+ display: flex; flex-direction: column; gap: 3px; padding: 9px 10px;
+ color: var(--text) !important; text-decoration: none; border-radius: 7px;
+}
+.navbar .fbadge-menu a:hover { background: var(--bg) !important; }
+.fbadge-qid { font-size: 0.72rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--primary); }
+.fbadge-excerpt { font-size: 0.8rem; color: var(--text-muted); }
+.fbadge-message { font-size: 0.84rem; overflow-wrap: anywhere; }
diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx
index fe4cf23..c7cf1e4 100644
--- a/frontend/src/pages/CategoriesPage.jsx
+++ b/frontend/src/pages/CategoriesPage.jsx
@@ -461,7 +461,7 @@ export default function CategoriesPage() {
Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.
-
Question bank
+
Questions
setCreating(v => !v)}>
+ New {facet.singular}
diff --git a/frontend/src/pages/QbankPage.css b/frontend/src/pages/QbankPage.css
new file mode 100644
index 0000000..320031e
--- /dev/null
+++ b/frontend/src/pages/QbankPage.css
@@ -0,0 +1,36 @@
+/* The Qbank landing: start a session, and see the last few. */
+
+.qb-page { max-width: 880px; margin: 0 auto; padding-bottom: 32px; }
+.qb-page h1 { margin: 0 0 18px; font-size: 1.6rem; font-weight: 700; }
+
+.qb-start {
+ padding: 24px 26px; margin-bottom: 30px;
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
+}
+.qb-start h2 { margin: 0 0 6px; font-size: 1.12rem; font-weight: 650; }
+.qb-start p { margin: 0 0 18px; max-width: 60ch; font-size: 0.9rem; line-height: 1.65; color: var(--text-muted); }
+
+.qb-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
+.qb-history-head h2 { margin: 0; font-size: 1.12rem; font-weight: 650; }
+.qb-history-head a { font-size: 0.86rem; font-weight: 600; color: var(--primary); text-decoration: none; }
+.qb-history-head a:hover { text-decoration: underline; }
+
+.qb-list { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
+.qb-list-head { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 0.88rem; font-weight: 650; }
+.qb-list ul { list-style: none; margin: 0; padding: 0; }
+.qb-list li + li { border-top: 1px solid var(--border); }
+
+.qb-row { display: flex; align-items: center; gap: 16px; padding: 15px 18px; flex-wrap: wrap; }
+.qb-row-main { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 8px; }
+.qb-row-title { font-size: 0.9rem; overflow-wrap: anywhere; }
+.qb-row-title strong { font-weight: 650; }
+.qb-row-title a { color: var(--text); text-decoration: none; }
+.qb-row-title a:hover { color: var(--primary); }
+.qb-row-actions { display: flex; gap: 8px; flex-shrink: 0; }
+
+.qb-empty { padding: 26px 18px; text-align: center; font-size: 0.88rem; color: var(--text-muted); }
+
+@media (max-width: 560px) {
+ .qb-row-actions { width: 100%; }
+ .qb-row-actions .btn { flex: 1; }
+}
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 6eac268..c3bf75c 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -1,970 +1,96 @@
-import { useState, useEffect, useRef, lazy, Suspense } from 'react'
-import { useLocation, useNavigate, Link } from 'react-router-dom'
-import RichText from '../components/RichText'
-import { useAuth } from '../context/AuthContext'
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
import api from '../api/client'
-import Dialog from '../components/Dialog'
-import CategoryTree from '../components/CategoryTree'
-import FacetPicker, { FacetRow } from '../components/FacetPicker'
-import CategoryDrilldown from '../components/CategoryDrilldown'
-import './CustomQuizPage.css' // facet row + picker panel styles
-import './QuestionBankPage.css'
-import { useDialog } from '../hooks/useDialog'
+import SessionProgress from '../components/SessionProgress'
+import './QbankPage.css'
-const TeachChat = lazy(() => import('../components/TeachChat'))
-const RichEditor = lazy(() => import('../components/RichEditor'))
-import QuestionReadingLinks from '../components/QuestionReadingLinks'
-
-const DIFFICULTY_LABEL = { '': 'Any', easy: 'Easy', medium: 'Medium', hard: 'Hard' }
-
-/** "All", one name, or the first name plus a +N badge. */
-const summarise = (names) => ({
- summary: names.length === 0 ? 'All' : names[0],
- extra: Math.max(0, names.length - 1),
-})
-
-function apiError(err, fallback) {
- const detail = err?.response?.data?.detail
- if (typeof detail === 'string') return detail
- if (Array.isArray(detail)) return detail.map(item => typeof item?.msg === 'string' ? item.msg : '').filter(Boolean).join('; ') || fallback
- return fallback
-}
-
-/** Strip HTML tags for plain text display (truncated cards). */
-function stripHtml(html) {
- if (!html) return ''
- return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim()
-}
+const RECENT = 3
/**
- * One question, shown whole.
+ * The Qbank: start a session, and pick up the last few.
*
- * It used to make you answer before it would reveal anything, which is the
- * right shape for practice and the wrong one here: in the bank the question is
- * being inspected, not sat. Everything is shown at once — the correct option,
- * the per-option reasoning, the explanation and the key points — because that
- * is what looking at a question means.
+ * It used to be a second browser over the question bank — facets, tag tree,
+ * favourites, create-a-quiz — all of which now live where they belong: the
+ * facets and session building on the custom-session page, marking and folders
+ * in the quiz player while you are actually sitting a question, and managing
+ * the questions themselves in the question manager. What is left is the thing
+ * this page is for.
+ *
+ * Three sessions, not all of them: this is a landing point, and the whole
+ * history is one link away.
*/
-function QuestionPreviewModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
- return (
- <>
-
e.target === e.currentTarget && onClose()}>
-
-
-
- {question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
-
-
- {onToggleFavorite && (
- onToggleFavorite(question.id)}
- title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.4rem', padding: 2, lineHeight: 1 }}>
- {isFavorited ? '⭐' : '☆'}
-
- )}
- ✕
-
-
- {/* Markdown, not injected HTML: a stem is educator prose, and the
- same renderer the quiz player uses keeps a lab table a table. */}
-
-
-
- {question.image_path && (
-
-
e.target.style.display = 'none'} />
-
- )}
- {question.options && (
-
- {question.options.map((opt, i) => {
- const isCorrectOpt = opt === question.correct_answer
- return (
-
- {String.fromCharCode(65 + i)}
- {opt}
- {isCorrectOpt && ✓ Correct }
- {question.option_explanations?.[opt] && (
-
-
-
- )}
-
- )
- })}
-
- )}
- {(question.explanation || question.explanation_image_path) && (
-
-
Explanation:
- {question.explanation &&
}
- {question.explanation_image_path && (
-
-
e.currentTarget.style.display = 'none'} />
-
- )}
-
- )}
- {(question.key_points || []).length > 0 && (
-
-
Key points
-
- {question.key_points.map((point, i) => (
-
- {point.text}
- {point.article_id && (
- 📖 Read more
- )}
-
- ))}
-
-
- )}
-
-
-
Add to library
-
- {
- if (!e.target.value) return
- await api.put(`/collections/${e.target.value}/questions/${question.id}`)
- e.target.value = ''
- }} aria-label="Add to collection">
- Choose collection…
- {collections.map(c => {c.title} )}
-
- {
- if (e.key === 'Enter' && e.target.value.trim()) {
- const res = await api.post('/collections/', { title: e.target.value.trim() })
- await api.put(`/collections/${res.data.id}/questions/${question.id}`)
- setCollections(prev => [...prev, res.data])
- e.target.value = ''
- }
- }} style={{ padding: '4px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem' }} />
-
-
-
- Close
-
-
-
- {/* AI Tutor — z-index above the modal */}
-
-
-
- >
- )
-}
-
-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(`/study/${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(`/study/${res.data.id}`)
- }
- } catch (err) { setError(apiError(err, 'Could not create quiz')) }
- finally { setLoading(false) }
- }
-
- const fromCategory = !!form.question_category_id
-
- return (
-
-
-
Create Quiz
- {error &&
{error}
}
-
- Source
- setForm(f => ({ ...f, question_category_id: e.target.value }))}>
- {selectedIds.size} selected question{selectedIds.size !== 1 ? 's' : ''}
- {categories.map(c => All from: {c.name} ({c.question_count} questions) )}
-
-
-
- Quiz Title
- setForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter title..." />
-
-
- Mode
- setForm(f => ({ ...f, mode: e.target.value }))}>
- Timed (Exam)
- Learning (Study)
-
-
-
- Time Limit (minutes, optional)
- setForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} />
-
-
- {loading ? 'Creating…' : 'Create Quiz'}
- Cancel
-
-
-
- )
-}
-
-function TagBrowser({ tags, selectedTagIds, toggleTag }) {
- const [search, setSearch] = useState({ subjects: '', diseases: '', keywords: '' })
- const sections = [
- { key: 'subjects', label: 'Subjects', color: '#8b5cf6', items: tags.subjects || [] },
- { key: 'diseases', label: 'Diseases', color: '#ef4444', items: tags.diseases || [] },
- { key: 'keywords', label: 'Keywords', color: '#0ea5e9', items: tags.keywords || [] },
- ]
- return (
-
- {sections.map(sec => {
- if (sec.items.length === 0) return null
- const q = search[sec.key].toLowerCase()
- const filtered = q ? sec.items.filter(t => t.name.toLowerCase().includes(q)) : sec.items
- return (
-
-
- {sec.label} ({sec.items.length})
-
-
setSearch(s => ({ ...s, [sec.key]: e.target.value }))}
- style={{ width: '100%', padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.78rem', background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 6, boxSizing: 'border-box' }} />
-
- {filtered.map(tag => {
- const selected = selectedTagIds.includes(tag.id)
- return (
-
toggleTag(tag.id)}
- style={{
- display: 'block', width: '100%', textAlign: 'left',
- background: selected ? sec.color : 'transparent',
- color: selected ? '#fff' : 'var(--text)',
- border: 'none', borderRadius: 4, padding: '3px 8px',
- fontSize: '0.78rem', cursor: 'pointer', marginBottom: 1,
- }}>
- {tag.name} ({tag.count})
-
- )
- })}
- {filtered.length === 0 &&
No matches
}
-
-
- )
- })}
-
- )
-}
-
-
-
-
export default function QuestionBankPage() {
- // Where the editor should send you back to, filters and all.
- const location = useLocation()
- const { dialogProps, openAlert } = useDialog()
- const [questions, setQuestions] = useState([])
- const [total, setTotal] = useState(0)
- const [loading, setLoading] = useState(false)
- const [categories, setCategories] = useState([])
- const [searchQuery, setSearchQuery] = useState('')
- const [difficulty, setDifficulty] = useState('')
- const [filtersOpen, setFiltersOpen] = useState(false)
- const [bankArticleIds, setBankArticleIds] = useState([])
- const [articles, setArticles] = useState([])
- const [collections, setCollections] = useState([])
- useEffect(() => {
- api.get('/articles/').then(res => setArticles(res.data || [])).catch(() => setArticles([]))
- api.get('/collections/').then(res => setCollections(res.data || [])).catch(() => setCollections([]))
- }, [])
- const [filterCatIds, setFilterCatIds] = useState([])
- const [showUncategorized, setShowUncategorized] = useState(false)
- const [showFavorites, setShowFavorites] = useState(false)
- const [showMyQuestions, setShowMyQuestions] = useState(false)
- const [favorites, setFavorites] = useState([])
- const [offset, setOffset] = useState(0)
- const [studyQuestion, setStudyQuestion] = useState(null)
- const [selectedIds, setSelectedIds] = useState(new Set())
- const [showCreateQuiz, setShowCreateQuiz] = useState(false)
- const [newCatName, setNewCatName] = useState('')
- const [catParent, setCatParent] = useState('')
- const [editingCategory, setEditingCategory] = useState(null)
- const [showCatForm, setShowCatForm] = useState(false)
- const [assignCatId, setAssignCatId] = useState('')
- const [bulkError, setBulkError] = useState('')
- const [pageSize, setPageSize] = useState(50)
- const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
- const [selectedTagIds, setSelectedTagIds] = useState([])
- const [openFacet, setOpenFacet] = useState(null)
- const [showTags, setShowTags] = useState(false)
- const [showImport, setShowImport] = useState(false)
- const [importFile, setImportFile] = useState(null)
- const [importing, setImporting] = useState(false)
- const [importResult, setImportResult] = useState(null)
- const [qtiImporting, setQtiImporting] = useState(false)
- const [qtiExporting, setQtiExporting] = useState(false)
- const debounceRef = useRef(null)
- const { user } = useAuth()
- const isModerator = user?.role === 'admin' || user?.role === 'moderator'
- const LIMIT = pageSize
-
- const loadTags = () => {
- api.get('/tags').then(res => setTags(res.data)).catch(() => {})
- }
+ const [sessions, setSessions] = useState([])
+ const [loading, setLoading] = useState(true)
useEffect(() => {
- api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
- api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {})
- loadTags()
+ let live = true
+ api.get('/quizzes/sessions')
+ .then(res => { if (live) setSessions(Array.isArray(res.data) ? res.data : []) })
+ .catch(() => { if (live) setSessions([]) })
+ .finally(() => { if (live) setLoading(false) })
+ return () => { live = false }
}, [])
- const loadQuestions = async (query = searchQuery, off = 0, catIds = filterCatIds, uncatOnly = showUncategorized, favOnly = showFavorites, size = pageSize, tagIds = selectedTagIds) => {
- setLoading(true)
- try {
- const params = { limit: size === 'all' ? 5000 : size, offset: off }
- if (query.trim()) params.q = query.trim()
- if (catIds.length > 0) params.category_ids = catIds.join(',')
- if (uncatOnly) params.uncategorized = true
- if (favOnly) params.favorites_only = true
- if (showMyQuestions) params.my_questions = true
- if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
- const res = await api.get('/questions/bank', { params })
- setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
- setTotal(res.data.total)
- setOffset(off)
- } catch { } finally { setLoading(false) }
- }
-
- const tagIdsKey = selectedTagIds.join(',')
- const catIdsKey = filterCatIds.join(',')
- useEffect(() => {
- clearTimeout(debounceRef.current)
- debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, pageSize, selectedTagIds), 300)
- return () => clearTimeout(debounceRef.current)
- }, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')])
-
- const subjectTags = tags.subjects || []
- const diseaseTags = tags.diseases || []
- const keywordTags = tags.keywords || []
- const nameOf = (list, id) => list.find(t => t.id === id)?.name
- const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
-
- const systemTags = tags.systems || []
- const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
- const organSystemsFacet = summarise(selectedIn(systemTags))
- const disciplinesFacet = summarise(selectedIn(subjectTags))
- const diseasesFacet = summarise(selectedIn(diseaseTags))
- const symptomsFacet = summarise(selectedIn(keywordTags))
- const articlesFacet = summarise(bankArticleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
- const statusFacet = showFavorites ? { summary: 'Saved', active: true }
- : showMyQuestions ? { summary: 'My questions', active: true }
- : showUncategorized ? { summary: 'Uncategorized', active: true }
- : { summary: 'All', active: false }
-
- const activeFilterCount = filterCatIds.length + selectedTagIds.length + bankArticleIds.length
- + (difficulty ? 1 : 0) + (statusFacet.active ? 1 : 0)
-
- const resetFilters = () => {
- setFilterCatIds([]); setSelectedTagIds([]); setBankArticleIds([]); setDifficulty('')
- setShowFavorites(false); setShowMyQuestions(false); setShowUncategorized(false)
- }
-
- const setStatus = (value) => {
- setShowFavorites(value === 'favorites')
- setShowMyQuestions(value === 'mine')
- setShowUncategorized(value === 'uncategorized')
- }
-
- /** Checklist for a tag vocabulary: most-used first, everything reachable by search. */
- const tagChecklist = (list, query) => {
- const shown = query
- ? list.filter(t => t.name.toLowerCase().includes(query))
- : list.slice(0, 60) // the tail is long; search reaches it
- if (!shown.length) return
Nothing matches that search.
- return (
- <>
- {shown.map(tag => (
-
- toggleTag(tag.id)} />
- {tag.name}
- {tag.count}
-
- ))}
- {!query && list.length > shown.length && (
-
{list.length - shown.length} more — search to narrow.
- )}
- >
- )
- }
-
- const toggleTag = (tagId) => {
- setSelectedTagIds(prev => {
- const n = prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
- return n
- })
- }
-
- // AI classification runs from the command line only; no UI trigger by design.
-
- useEffect(() => {
- return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
- }, [])
-
- const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
-
- const selectAll = async () => {
- // Fetch ALL matching IDs from server (not just loaded page)
- try {
- const params = {}
- if (searchQuery.trim()) params.q = searchQuery.trim()
- if (difficulty) params.difficulty = difficulty
- if (bankArticleIds.length) params.article_ids = bankArticleIds.join(',')
- if (filterCatIds.length > 0) params.category_ids = filterCatIds.join(',')
- if (showUncategorized) params.uncategorized = true
- if (showFavorites) params.favorites_only = true
- if (selectedTagIds.length > 0) params.tag_ids = selectedTagIds.join(',')
- const res = await api.get('/questions/bank/ids', { params })
- setSelectedIds(new Set(res.data))
- } catch {
- // Fallback to loaded questions
- setSelectedIds(new Set(questions.map(q => q.id)))
- }
- }
-
- const clearSelection = () => setSelectedIds(new Set())
-
- const bulkAssignCategory = async () => {
- if (!assignCatId) return
- setBulkError('')
- const catIdVal = assignCatId === 'remove' ? null : parseInt(assignCatId)
- try {
- await api.post('/questions/bulk-category', {
- question_ids: [...selectedIds],
- category_id: catIdVal,
- })
- const catName = catIdVal ? categories.find(c => c.id === catIdVal)?.name ?? null : null
- setQuestions(prev => prev.map(q => selectedIds.has(q.id)
- ? { ...q, question_category_id: catIdVal, question_category_name: catName }
- : q))
- clearSelection()
- setAssignCatId('')
- api.get('/question-categories/').then(res => setCategories(res.data))
- } catch (err) {
- setBulkError(apiError(err, 'Failed to assign category'))
- }
- }
-
- const addCategory = async () => {
- if (!newCatName.trim()) return
- try {
- const payload = { name: newCatName.trim(), parent_id: catParent ? Number(catParent) : null, description: editingCategory?.description || null }
- if (editingCategory) await api.patch(`/question-categories/${editingCategory.id}`, payload)
- else await api.post('/question-categories/', payload)
- const res = await api.get('/question-categories/')
- setCategories(res.data)
- setNewCatName(''); setCatParent(''); setEditingCategory(null); setShowCatForm(false)
- clearSelection()
- setQuestions([])
- setTotal(0)
- await loadQuestions()
- } catch (err) { await openAlert(apiError(err, 'Could not save category'), { title: 'Error' }) }
- }
-
- const [deletingCatId, setDeletingCatId] = useState(null)
- const [moveToCatId, setMoveToCatId] = useState('')
-
- const confirmDeleteCategory = async () => {
- const catId = deletingCatId
- const moveTo = moveToCatId ? parseInt(moveToCatId) : undefined
- setDeletingCatId(null); setMoveToCatId('')
- try {
- await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
- setCategories(prev => prev.filter(c => c.id !== catId))
- setFilterCatIds(prev => prev.filter(c => c !== catId))
- loadQuestions(searchQuery, 0, filterCatIds.filter(c => c !== catId), showUncategorized, showFavorites)
- } catch (err) { await openAlert(apiError(err, 'Could not delete category'), { title: 'Error' }) }
- }
-
- const deleteCategory = (catId) => {
- setDeletingCatId(catId)
- setMoveToCatId('')
- }
-
- const handleImport = async () => {
- if (!importFile) return
- setImporting(true)
- setImportResult(null)
- try {
- const fd = new FormData()
- fd.append('file', importFile)
- const res = await api.post('/questions/import/upload', fd)
- setImportResult(res.data)
- if (res.data.imported > 0) {
- loadQuestions()
- api.get('/question-categories/').then(r => setCategories(r.data))
- }
- } catch (err) {
- setImportResult({ error: apiError(err, 'Import failed') })
- } finally {
- setImporting(false)
- }
- }
-
- const handleQtiImport = () => {
- if (qtiImporting) return
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = '.xml'
- input.onchange = async (e) => {
- const file = e.target.files?.[0]
- if (!file) return
- setQtiImporting(true)
- try {
- const fd = new FormData()
- fd.append('file', file)
- const res = await api.post('/questions/import/qti', fd)
- const errors = res.data.errors?.length ? ` ${res.data.errors.length} error(s).` : ''
- openAlert(`Imported ${res.data.imported} of ${res.data.total_items} questions.${errors}`, { title: 'QTI Import Complete' })
- loadQuestions()
- } catch (err) {
- openAlert(apiError(err, 'QTI import failed'), { title: 'Import Failed' })
- } finally {
- setQtiImporting(false)
- }
- }
- input.click()
- }
-
- const handleQtiExport = async () => {
- setQtiExporting(true)
- try {
- const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
- const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
- const url = URL.createObjectURL(res.data)
- const a = document.createElement('a')
- a.href = url
- a.download = 'questions_qti.xml'
- a.click()
- URL.revokeObjectURL(url)
- } catch (err) {
- openAlert(apiError(err, 'QTI export failed'), { title: 'Export Failed' })
- } finally {
- setQtiExporting(false)
- }
- }
-
- const toggleFavorite = async (questionId) => {
- const isFavorited = favorites.includes(questionId)
- try {
- if (isFavorited) {
- await api.delete(`/favorites/${questionId}`)
- setFavorites(prev => prev.filter(id => id !== questionId))
- } else {
- await api.post('/favorites', { question_id: questionId })
- setFavorites(prev => [...prev, questionId])
- }
- } catch (err) {
- await openAlert(apiError(err, 'Failed to update favorite'), { title: 'Error' })
- }
- }
+ // Only sessions actually begun: an untouched study-plan block is material,
+ // not history.
+ const recent = sessions.filter(row => row.state !== 'not_started').slice(0, RECENT)
return (
-
-
- {/* Delete category dialog */}
- {deletingCatId && (() => {
- const cat = categories.find(c => c.id === deletingCatId)
- const others = categories.filter(c => c.id !== deletingCatId)
- return (
-
-
-
Delete "{cat?.name}"?
-
This affects all assigned questions, including private and course questions excluded from the visible count.
-
- Move all assigned questions to:
- setMoveToCatId(e.target.value)}>
- Leave uncategorized
- {others.map(c => {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name} )}
-
-
-
- Delete category
- { setDeletingCatId(null); setMoveToCatId('') }}>Cancel
-
-
-
- )
- })()}
+
+
Qbank
- {studyQuestion &&
setStudyQuestion(null)}
- isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
- {showCreateQuiz && setShowCreateQuiz(false)} onCreated={() => {}} />}
+
+ Create a session
+
+ Choose the topics, systems or articles you want, how many questions, and
+ whether to sit it as study or as an exam. Every session counts towards
+ your performance analysis.
+
+ Create a session
+
- {/* Import CSV/Excel Modal */}
- {showImport && (
-
-
-
-
Import Questions
- setShowImport(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕
-
-
- Upload a CSV or Excel (.xlsx) file with columns: question_text , option_a through option_e , correct_answer (A/B/C/D/E), explanation , category .
-
-
- Download sample CSV template
-
-
- { setImportFile(e.target.files[0]); setImportResult(null) }}
- style={{ fontSize: '0.88rem' }} />
-
- {importResult && !importResult.error && (
-
- Imported
{importResult.imported} of {importResult.total_rows} questions.
- {importResult.errors?.length > 0 && (
-
- {importResult.errors.map((e, i) =>
{e}
)}
+
+
Session history
+ See all ›
+
+
+
+
Latest sessions
+ {loading ? (
+
Loading…
+ ) : recent.length === 0 ? (
+
No sessions yet. Create one above to start.
+ ) : (
+
+ {recent.map(row => {
+ const attempt = row.active_attempt_id || row.last_attempt_id
+ return (
+
+
+
+
+ {row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'} {' '}
+ {attempt ? {row.title} : row.title}
+
+
+
+
+ {attempt && (
+ Analysis
+ )}
+
+ {row.state === 'completed' ? 'Sit again' : 'Resume'}
+
+
- )}
-
- )}
- {importResult?.error && (
-
{importResult.error}
- )}
-
-
- {importing ? 'Importing...' : 'Upload & Import'}
-
- setShowImport(false)}>Close
-
-
-
- )}
-
- {/* Header */}
-
-
-
-
Question Bank
-
{total} questions total
-
-
- {selectedIds.size > 0 && (
- setShowCreateQuiz(true)}>
- Create Quiz ({selectedIds.size} selected)
-
- )}
- {/* The full editor, not a cramped modal — the same page Edit
- opens, so writing a question and fixing one are one screen. */}
- + Question
- { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel
-
- {qtiImporting ? 'Importing QTI...' : 'Import QTI'}
-
-
- {qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
-
- {isModerator && { setEditingCategory(null); setNewCatName(''); setCatParent(''); setShowCatForm(v => !v) }}>+ Category }
-
-
-
- {showCatForm && (
-
e.target === e.currentTarget && (setShowCatForm(false), setEditingCategory(null))}>
-
-
- {editingCategory ? `Edit category: ${editingCategory.name}` : 'New category'}
- { setShowCatForm(false); setNewCatName(''); setCatParent(''); setEditingCategory(null) }}>✕
-
-
setNewCatName(e.target.value)} placeholder="Category name..."
- onKeyDown={e => e.key === 'Enter' && addCategory()}
- style={{ width: '100%', padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 8 }} />
-
setCatParent(e.target.value)}>
- No parent (root)
- {categories.filter(c => !editingCategory || !(c.breadcrumbs || [{ id: c.id }]).some(b => b.id === editingCategory.id)).map(c => {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name} )}
-
-
- {editingCategory ? 'Save category' : 'Add'}
- { setShowCatForm(false); setNewCatName(''); setCatParent(''); setEditingCategory(null) }}>Cancel
-
-
-
+
+ )
+ })}
+
)}
-
- {/* Filters — one row per facet, each opening a search + checklist panel. */}
-
-
- setFiltersOpen(v => !v)}>
- ⚙ Filters
- {activeFilterCount > 0 && {activeFilterCount} }
-
- {activeFilterCount > 0 && (
- Clear all
- )}
- {total} question{total !== 1 ? 's' : ''}
-
-
- {filtersOpen && (
-
- setOpenFacet('status')} />
- setOpenFacet('difficulty')} />
- setOpenFacet('organ-systems')} />
- setOpenFacet('systems')} />
- setOpenFacet('disciplines')} />
- setOpenFacet('diseases')} />
- setOpenFacet('symptoms')} />
- setOpenFacet('articles')} />
-
- )}
-
- {/* Main column */}
-
-
- {/* AI question classification is intentionally not exposed in the UI;
- it remains available through the command line/Celery interface only. */}
-
- {/* Search row */}
-
-
- 🔍
- 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)' }} />
-
- {/* Page size */}
-
- Show:
- setPageSize(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
- style={{ padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
- 50
- 100
- 200
- All
-
-
-
-
- {/* Bulk actions row — only shown for moderators, clearly separated */}
- {isModerator && (
-
-
- Select all {total > 0 ? `(${total})` : ''}
-
- {selectedIds.size > 0 && (
- <>
- {selectedIds.size} selected
- Clear
- setAssignCatId(e.target.value)}
- style={{ padding: '5px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
- Move to category…
- — Remove category
- {categories.map(c => {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name} )}
-
- Apply
- setShowCreateQuiz(true)}>Create Quiz
- >
- )}
- {bulkError && {bulkError} }
-
- )}
-
- {/* 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}
- {q.question_category_name && (
-
- {q.question_category_name}
-
- )}
-
-
- {stripHtml(q.question_text).slice(0, 200)}{stripHtml(q.question_text).length > 200 ? '...' : ''}
-
- {q.options && (
-
- {q.options.map((opt, i) => (
-
- {String.fromCharCode(65 + i)}. {opt.slice(0, 50)}{opt.length > 50 ? '…' : ''}
-
- ))}
-
- )}
-
-
- toggleFavorite(q.id)}
- title={favorites.includes(q.id) ? 'Remove from favorites' : 'Add to favorites'}
- style={{
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- fontSize: '1.3rem',
- padding: 4,
- lineHeight: 1,
- }}
- >
- {favorites.includes(q.id) ? '⭐' : '☆'}
-
- setStudyQuestion(q)}>Preview
- {(q.user_id === user?.id || isModerator) && (
- {
- const newVal = q.is_shared ? 0 : 1
- try {
- await api.patch(`/questions/${q.id}/share?shared=${newVal}`)
- setQuestions(prev => prev.map(x => x.id === q.id ? { ...x, is_shared: newVal } : x))
- } catch {}
- }}>{q.is_shared ? 'Unshare' : 'Share'}
- )}
- {/* The whole question, on its own page: the modal could not show
- option explanations, images, versions or categories at once. */}
- {isModerator && Edit}
- {isModerator && {
- if (!await openAlert(`Delete "${stripHtml(q.question_text).slice(0, 80)}..."? This removes it from all quizzes.`, { title: 'Delete Question', confirmLabel: 'Delete', cancelLabel: 'Cancel' })) return
- try {
- await api.delete(`/questions/${q.id}`)
- setQuestions(prev => prev.filter(x => x.id !== q.id))
- setTotal(t => t - 1)
- } catch (err) { await openAlert(apiError(err, 'Delete failed'), { title: 'Error' }) }
- }}>Delete }
-
-
- ))}
-
- {!loading && questions.length === 0 && (
-
- )}
-
- {questions.length < total && (
-
- loadQuestions(searchQuery, questions.length, filterCatIds, showUncategorized, showFavorites, pageSize, selectedTagIds)} disabled={loading}>
- {loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
-
-
- )}
-
-
-
-
setOpenFacet(null)}
- onReset={() => setStatus('all')} helper="Narrow to a slice of the bank.">
- {() => [['all', `All (${total})`], ['favorites', `Saved (${favorites.length})`],
- ['mine', 'My questions'], ['uncategorized', 'Uncategorized']].map(([value, label]) => (
-
- setStatus(value)} />
- {label}
-
- ))}
-
-
-
setOpenFacet(null)}
- onReset={() => setDifficulty('')} helper="Applies to every question listed.">
- {() => ['', 'easy', 'medium', 'hard'].map(value => (
-
- setDifficulty(value)} />
- {DIFFICULTY_LABEL[value]}
-
- ))}
-
-
-
setOpenFacet(null)}
- onReset={() => setSelectedTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
- helper="Body systems. Flat by nature — there are only so many.">
- {query => tagChecklist(systemTags, query)}
-
-
-
setOpenFacet(null)}
- onReset={() => setFilterCatIds([])}
- helper="Open a system to see what is under it. Choosing one includes everything beneath.">
- {query => (
- setFilterCatIds(ids => on ? [...ids, id] : ids.filter(c => c !== id))} />
- )}
-
-
-
setOpenFacet(null)}
- onReset={() => setSelectedTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
- helper="Specialty areas, most used first.">
- {query => tagChecklist(subjectTags, query)}
-
-
-
setOpenFacet(null)}
- onReset={() => setSelectedTagIds(ids => ids.filter(id => !diseaseTags.some(t => t.id === id)))}
- helper="Named conditions, most used first.">
- {query => tagChecklist(diseaseTags, query)}
-
-
-
setOpenFacet(null)}
- onReset={() => setSelectedTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))}
- helper="Presenting features and keywords, most used first.">
- {query => tagChecklist(keywordTags, query)}
-
-
-
setOpenFacet(null)}
- onReset={() => setBankArticleIds([])} helper="Questions linked to a topic article.">
- {query => {
- const shown = articles.filter(a => !query || (a.title || '').toLowerCase().includes(query))
- if (!shown.length) return No articles match.
- return shown.map(article => (
-
- setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
- {article.title}
-
- ))
- }}
-
)
}
diff --git a/frontend/src/pages/QuestionBankPage.test.jsx b/frontend/src/pages/QuestionBankPage.test.jsx
index e9288d7..d56d528 100644
--- a/frontend/src/pages/QuestionBankPage.test.jsx
+++ b/frontend/src/pages/QuestionBankPage.test.jsx
@@ -1,161 +1,75 @@
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
import QuestionBankPage from './QuestionBankPage'
import api from '../api/client'
-vi.mock('../api/client', () => ({
- default: {
- get: vi.fn(),
- post: vi.fn(),
- patch: vi.fn(),
- delete: vi.fn(),
- },
-}))
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
-vi.mock('../context/AuthContext', () => ({
- useAuth: () => ({ user: { role: 'admin' } }),
-}))
+const SESSIONS = [
+ { quiz_id: 1, title: 'My Custom Test', mode: 'learning', state: 'in_progress',
+ answered: 3, total: 20, active_attempt_id: 91, last_attempt_id: null },
+ { quiz_id: 2, title: 'Board Review IX', mode: 'timed', state: 'completed',
+ answered: 40, total: 40, last_score: 31, active_attempt_id: null, last_attempt_id: 92 },
+ { quiz_id: 3, title: 'Neonatology drill', mode: 'learning', state: 'completed',
+ answered: 10, total: 10, last_score: 8, active_attempt_id: null, last_attempt_id: 93 },
+ { quiz_id: 4, title: 'Older still', mode: 'timed', state: 'completed',
+ answered: 5, total: 5, last_score: 5, active_attempt_id: null, last_attempt_id: 94 },
+ { quiz_id: 5, title: 'Board Review XII', mode: 'timed', state: 'not_started',
+ answered: 0, total: 202, active_attempt_id: null, last_attempt_id: null },
+]
-function mockInitialRequests() {
- api.get.mockImplementation((url) => {
- if (url === '/question-categories/') return Promise.resolve({ data: [] })
- if (url === '/favorites') return Promise.resolve({ data: [] })
- if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
- if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
- return Promise.resolve({ data: [] })
- })
+const mount = (rows = SESSIONS) => {
+ api.get.mockResolvedValue({ data: rows })
+ return render(
)
}
-function renderPage() {
- return render(
-
-
-
- )
-}
-async function openFilters() {
- await userEvent.click(await screen.findByRole('button', { name: /Filters/ }))
- await screen.findByRole('dialog', { name: 'Question filters' })
-}
+describe('the Qbank landing', () => {
+ beforeEach(() => { vi.clearAllMocks() })
-describe('QuestionBankPage QTI actions', () => {
- let originalCreateElement
- let fileInput
-
- beforeEach(() => {
- vi.clearAllMocks()
- mockInitialRequests()
- originalCreateElement = document.createElement.bind(document)
- fileInput = null
-
- vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
- const element = originalCreateElement(tagName, options)
- if (tagName === 'input') {
- fileInput = element
- vi.spyOn(element, 'click').mockImplementation(() => {})
- }
- return element
- })
+ it('leads with starting a session', async () => {
+ mount()
+ expect(await screen.findByRole('heading', { name: 'Create a session' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Create a session' })).toHaveAttribute('href', '/study/new')
})
- afterEach(() => {
- document.createElement.mockRestore()
+ it('shows the latest three and sends you elsewhere for the rest', async () => {
+ mount()
+ await screen.findByText('My Custom Test')
+ const list = document.querySelector('.qb-list')
+ // Three, not everything: this is a landing point and the history is a link away.
+ expect(within(list).getAllByRole('listitem')).toHaveLength(3)
+ expect(within(list).queryByText('Older still')).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /See all/ })).toHaveAttribute('href', '/sessions')
})
- it('imports QTI files with an in-app success dialog', async () => {
- api.post.mockResolvedValueOnce({ data: { imported: 3, total_items: 4, errors: ['Skipped duplicate'] } })
-
- renderPage()
- await userEvent.click(await screen.findByRole('button', { name: 'Import QTI' }))
-
- expect(fileInput).toBeTruthy()
- expect(fileInput.accept).toBe('.xml')
-
- const file = new File(['
'], 'questions.xml', { type: 'text/xml' })
- Object.defineProperty(fileInput, 'files', { value: [file], configurable: true })
- fireEvent.change(fileInput)
-
- await waitFor(() => {
- expect(api.post).toHaveBeenCalledWith('/questions/import/qti', expect.any(FormData))
- })
- expect(await screen.findByText('QTI Import Complete')).toBeInTheDocument()
- expect(screen.getByText('Imported 3 of 4 questions. 1 error(s).')).toBeInTheDocument()
+ it('leaves out material nobody has started — that is not history', async () => {
+ mount()
+ await screen.findByText('My Custom Test')
+ expect(screen.queryByText('Board Review XII')).not.toBeInTheDocument()
})
- it('shows QTI export failures instead of swallowing them', async () => {
- api.get.mockImplementation((url) => {
- if (url.startsWith('/questions/export/qti')) {
- return Promise.reject({ response: { data: { detail: 'Export unavailable' } } })
- }
- if (url === '/question-categories/') return Promise.resolve({ data: [] })
- if (url === '/favorites') return Promise.resolve({ data: [] })
- if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
- if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
- return Promise.resolve({ data: [] })
- })
+ it('resumes what is unfinished and offers another sitting of what is done', async () => {
+ mount()
+ const live = (await screen.findByText('My Custom Test')).closest('.qb-row')
+ expect(within(live).getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/study/1')
+ expect(within(live).getByRole('link', { name: 'Analysis' })).toHaveAttribute('href', '/sessions/91')
- renderPage()
- await userEvent.click(await screen.findByRole('button', { name: 'Export QTI' }))
+ const done = screen.getByText('Board Review IX').closest('.qb-row')
+ expect(within(done).getByRole('link', { name: 'Sit again' })).toHaveAttribute('href', '/study/2')
+ expect(within(done).getByRole('link', { name: 'Analysis' })).toHaveAttribute('href', '/sessions/92')
+ })
- expect(await screen.findByText('Export Failed')).toBeInTheDocument()
- expect(screen.getByText('Export unavailable')).toBeInTheDocument()
- })
-})
-
-describe('QuestionBankPage review regressions', () => {
- beforeEach(() => { vi.resetAllMocks(); mockInitialRequests() })
-
- it('renders validation arrays for category creation without crashing', async () => {
- api.post.mockRejectedValue({ response: { data: { detail: [{ msg: 'Category name is too long' }] } } })
- renderPage()
- await userEvent.click(await screen.findByRole('button', { name: '+ Category' }))
- const name = screen.getByLabelText('Category name')
- expect(name).toHaveAttribute('maxlength', '200')
- await userEvent.type(name, 'New category')
- await userEvent.click(screen.getByRole('button', { name: 'Add', exact: true }))
- expect(await screen.findByText('Category name is too long')).toBeInTheDocument()
- })
-
- it('handles selected-quiz validation arrays and bounds the title', async () => {
- const initial = api.get.getMockImplementation()
- api.get.mockImplementation(url => url === '/questions/bank' ? Promise.resolve({ data: {
- total: 1, questions: [{ id: 10, question_text: 'A full question', options: ['Yes', 'No'], correct_answer: 'Yes', question_type: 'mcq' }],
- } }) : initial(url))
- api.post.mockRejectedValue({ response: { data: { detail: [{ msg: 'Quiz title is invalid' }] } } })
- renderPage()
- await screen.findByText('A full question')
- await userEvent.click(screen.getByRole('checkbox'))
- await userEvent.click(screen.getByRole('button', { name: 'Create Quiz (1 selected)' }))
- const title = screen.getByLabelText('Quiz Title')
- expect(title).toHaveAttribute('maxlength', '200')
- await userEvent.type(title, 'My quiz')
- await userEvent.click(within(screen.getByRole('dialog', { name: 'Create Quiz' })).getByRole('button', { name: 'Create Quiz', exact: true }))
- expect(await screen.findByText('Quiz title is invalid')).toBeInTheDocument()
- })
-
-})
-
-describe('QuestionBankPage edit modal multi-category', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockInitialRequests()
- })
-
- it('opens the full editor rather than a modal, and says where to come back to', async () => {
- const question = { id: 7, question_text: 'Edit me', question_type: 'mcq', options: ['A', 'B'], correct_answer: 'A', explanation: '', question_category_id: 1, category_ids: [1] }
- const initialGet = api.get.getMockImplementation()
- api.get.mockImplementation(url => url === '/questions/bank'
- ? Promise.resolve({ data: { questions: [question], total: 1 } }) : initialGet(url))
- renderPage()
-
- // A modal could not show option explanations, images, versions and
- // categories at once, which is what editing a question actually needs.
- const edit = await screen.findByRole('link', { name: 'Edit' })
- expect(edit).toHaveAttribute('href', '/questions/7')
- await userEvent.click(edit)
- expect(screen.queryByRole('dialog', { name: 'Edit Question' })).not.toBeInTheDocument()
+ it('says so plainly when nothing has been sat', async () => {
+ mount([])
+ expect(await screen.findByText(/No sessions yet/)).toBeInTheDocument()
+ })
+
+ it('copes when the sessions cannot be loaded', async () => {
+ api.get.mockRejectedValue(new Error('down'))
+ render(
)
+ expect(await screen.findByText(/No sessions yet/)).toBeInTheDocument()
+ // The thing this page is for still works.
+ expect(screen.getByRole('link', { name: 'Create a session' })).toBeInTheDocument()
})
})
diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx
index 3f36eb7..0f6f5b1 100644
--- a/frontend/src/pages/QuestionEditPage.jsx
+++ b/frontend/src/pages/QuestionEditPage.jsx
@@ -6,6 +6,7 @@ import RichText from '../components/RichText'
import ImagePicker from '../components/ImagePicker'
import FigureManager from '../components/FigureManager'
import MarkdownToolbar from '../components/MarkdownToolbar'
+import FeedbackPanel from '../components/FeedbackPanel'
import { uploadUrl } from '../utils/uploads'
import './QuestionEditPage.css'
@@ -51,8 +52,11 @@ export default function QuestionEditPage({ mode = 'edit' }) {
// Back to wherever you opened this from — the bank with its filters, an
// article, the manager — rather than always to the bank you may not have used.
const { state } = useLocation()
- const backTo = state?.from || '/question-bank'
- const backLabel = state?.label || 'Question bank'
+ // The questions page, not the Qbank landing: that is where questions are
+ // listed now, and returning to a page with no questions on it is not a way
+ // back from editing one.
+ const backTo = state?.from || '/questions/manage'
+ const backLabel = state?.label || 'Questions'
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
@@ -394,6 +398,17 @@ export default function QuestionEditPage({ mode = 'edit' }) {
)}
+ {!isCreate && (
+
+ Feedback
+
+ {/* Beside the fields it is about: the answer to almost every
+ report is a change to the question. */}
+
+
+
+ )}
+
Figures
diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx
index 4e3d0be..9da3ac7 100644
--- a/frontend/src/pages/QuestionManagerPage.jsx
+++ b/frontend/src/pages/QuestionManagerPage.jsx
@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link, useLocation } from 'react-router-dom'
+import QuestionPreview from '../components/QuestionPreview'
+import QuestionImport from '../components/QuestionImport'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import GrantsPanel from '../components/GrantsPanel'
@@ -48,6 +50,8 @@ export default function QuestionManagerPage() {
const [bulkBusy, setBulkBusy] = useState(false)
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
const [deletingId, setDeletingId] = useState(null)
+ // Looking at a question without leaving the list you found it in.
+ const [previewing, setPreviewing] = useState(null)
const debounceRef = useRef(null)
useEffect(() => {
@@ -127,9 +131,13 @@ export default function QuestionManagerPage() {
return (
+ {previewing && (
+
setPreviewing(null)} />
+ )}
+
-
Question manager
+
Questions
{scope && !scope.is_moderator
? `Your editorial grants cover ${scope.categories.length} categor${scope.categories.length === 1 ? 'y' : 'ies'}.`
@@ -138,9 +146,9 @@ export default function QuestionManagerPage() {
Taxonomy
- Open question bank
+
+ New question
+ state={{ from: '/questions/manage', label: 'Questions' }}>+ New question
@@ -220,8 +228,13 @@ export default function QuestionManagerPage() {
{/* The whole question on its own page. The modal could show
neither a searchable category tree nor images, versions and
option explanations at once, which is what editing needs. */}
+ {/* Preview opens over the list. Sending the reader to a
+ whole page to read one question, then back, was the
+ redundant part. */}
+ setPreviewing(q)}>Preview
Edit
+ state={{ from: `${location.pathname}${location.search}`, label: 'Questions' }}>Edit
{deletingId === q.id ? (
<>
deleteOne(q.id)}>Confirm
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index 73e060c..3aedd1e 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -1,6 +1,5 @@
import { uploadUrl } from '../utils/uploads'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
-import CommentSection from '../components/CommentSection'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import RichText from '../components/RichText'
@@ -9,6 +8,8 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import FigureStrip from '../components/FigureStrip'
+import FeedbackForm from '../components/FeedbackForm'
+import '../components/Feedback.css'
import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css'
@@ -310,6 +311,33 @@ function ShareLinkBadge({ quiz, onShareChanged }) {
)
}
+/** Occasional session actions, folded away until asked for. */
+function MoreActions({ children }) {
+ const [open, setOpen] = useState(false)
+ const wrap = useRef(null)
+
+ useEffect(() => {
+ if (!open) return undefined
+ const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) }
+ const onKey = e => { if (e.key === 'Escape') setOpen(false) }
+ document.addEventListener('mousedown', away)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', away)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ return (
+
+
setOpen(v => !v)}>⋯
+ {open &&
{children}
}
+
+ )
+}
+
+
function CourseQuizStart({ quiz, onStart, onShareChanged }) {
const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
const [error, setError] = useState('')
@@ -426,6 +454,15 @@ export default function QuizPage() {
const [statsError, setStatsError] = useState('')
const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0')
const [showAllExplanations, setShowAllExplanations] = useState(false)
+ // Which options have had their reasoning opened by clicking them. Separate
+ // from the show-all toggle so one does not fight the other.
+ const [openExplanations, setOpenExplanations] = useState(() => new Set())
+ const toggleOptionExplanation = (index) => setOpenExplanations(prev => {
+ const next = new Set(prev)
+ if (next.has(index)) next.delete(index)
+ else next.add(index)
+ return next
+ })
const toggleStats = () => setShowStats(value => {
localStorage.setItem('pedshub_show_stats', value ? '0' : '1')
return !value
@@ -556,6 +593,7 @@ export default function QuizPage() {
setActiveReadSegment(null)
setTtsActive(false)
setDraftAnswer('')
+ setOpenExplanations(new Set())
savedHighlightSelectionRef.current = null
clearTimeout(autoHighlightTimerRef.current)
if (!readThrough) setActiveReadSegment(null)
@@ -1202,7 +1240,6 @@ const timerStarted = timeLeft !== null
Q {currentIdx + 1} / {totalCount}
{answeredCount} answered
- setQuiz(q => ({ ...q, share_token: token }))} />
@@ -1269,21 +1306,11 @@ const timerStarted = timeLeft !== null
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
}}>
- {/* The category and the difficulty are both hints. Knowing a
- question is filed under Seizures & Epilepsy, or that it is
- "easy", narrows the answer before the stem has been read —
- so neither is shown until the answer is in. */}
+ {/* Difficulty is a hint, so it waits until the answer is in. The
+ category trail is gone entirely: it named the answer's own
+ topic and led out of a session you are part-way through. What
+ to read next belongs in the explanation, which links to it. */}
- {answerRevealed && current.category_breadcrumbs?.length > 0 && (
-
- {current.category_breadcrumbs.map((category, index) => (
-
- {index > 0 && › }
- {category.name}
-
- ))}
-
- )}
{answerRevealed && current.difficulty && (
{current.difficulty}
)}
@@ -1316,11 +1343,21 @@ const timerStarted = timeLeft !== null
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
✎ {note ? 'Notes' : 'Add notes'}
- setPanel(p => (p === 'save' ? null : 'save'))}>
- ⊞ Save
-
+ {/* Saving, sharing and reporting are occasional, so they fold
+ away behind one control rather than each taking a slot in a
+ bar that is read on every question. */}
+
+ setPanel(p => (p === 'save' ? null : 'save'))}>
+ ⊞ Save to a folder
+
+ setQuiz(q => ({ ...q, share_token: token }))} />
+
+ ⚑ Give feedback
+
+
+
toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
@@ -1471,9 +1508,16 @@ const timerStarted = timeLeft !== null
return (
!hasAnswered && !hasActiveTextSelection() && chooseAnswer(opt)}
+ onClick={() => {
+ if (hasActiveTextSelection()) return
+ if (!hasAnswered) return chooseAnswer(opt)
+ // Once answered, the option is a disclosure for its
+ // own reasoning: clicking it opens that, and clicking
+ // it again closes it.
+ if (current.option_explanations?.[opt]) toggleOptionExplanation(i)
+ }}
style={{
- cursor: hasAnswered ? 'default' : 'pointer',
+ cursor: hasAnswered && current.option_explanations?.[opt] ? 'pointer' : hasAnswered ? 'default' : 'pointer',
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
transition: 'background 0.15s ease, box-shadow 0.15s ease',
@@ -1495,7 +1539,7 @@ const timerStarted = timeLeft !== null
{showCorrect && ✓ Correct }
{showWrong && ✗ Wrong }
- {hasAnswered && showAllExplanations && current.option_explanations?.[opt] && (
+ {hasAnswered && (showAllExplanations || openExplanations.has(i)) && current.option_explanations?.[opt] && (
@@ -1568,7 +1612,6 @@ const timerStarted = timeLeft !== null
)}
-
{current.question_type === 'fill_blank' && (
Correct Answer: {current.correct_answer}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 87b2b6a..f631515 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -117,16 +117,17 @@ describe('quiz player', () => {
expect(meta).toBeInTheDocument()
// Category and difficulty are hints — being told a question is filed under
- // Neonatology, or that it is "hard", narrows the answer before the stem has
- // been read. They wait until the answer is in.
- expect(within(meta).queryByRole('link', { name: 'Pediatrics' })).not.toBeInTheDocument()
+ // "hard" narrows the answer before the stem has been read, so it waits
+ // until the answer is in.
expect(within(meta).queryByText('hard')).not.toBeInTheDocument()
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
- expect(await within(meta).findByRole('link', { name: 'Neonatology' })).toBeInTheDocument()
- expect(within(meta).getByText('hard')).toBeInTheDocument()
+ expect(await within(meta).findByText('hard')).toBeInTheDocument()
+ // The category trail is gone: it named the answer's own topic, and led out
+ // of a session you are part-way through.
+ expect(within(meta).queryByRole('link', { name: 'Neonatology' })).not.toBeInTheDocument()
// Mark moved off the stem into the action bar, so the stem is text only.
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
@@ -150,7 +151,6 @@ describe('quiz player', () => {
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
// Once the answer is in, the trail is a way to more of the same topic.
- expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/study/new?category=11')
fireEvent(window, new Event('pagehide'))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/progress', expect.objectContaining({ answers: { 1: 'First answer' } }), expect.any(Object)))
expect(api.post.mock.calls.some(([url]) => url.startsWith('/attempts/start'))).toBe(false)
@@ -248,6 +248,30 @@ describe('quiz player', () => {
expect(screen.queryByText('Full first clinical question.')).not.toBeInTheDocument()
})
+ it('opens one option\'s reasoning on click, and closes it on the next click', async () => {
+ const originalGet = api.get.getMockImplementation()
+ api.get.mockImplementation(async (url, ...args) => {
+ const res = await originalGet(url, ...args)
+ if (url.includes('attempt_id=')) {
+ res.data.questions[0] = { ...res.data.questions[0],
+ option_explanations: { 'First answer': 'Because it is first.' } }
+ }
+ return res
+ })
+ await begin()
+ await findStem('Full first clinical question.')
+ fireEvent.keyDown(window, { key: '1' })
+ fireEvent.keyDown(window, { key: 'Enter' })
+
+ const option = await waitFor(() => inCard().getByText('First answer').closest('button'))
+ expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument()
+ await userEvent.click(option)
+ expect(await screen.findByText('Because it is first.')).toBeInTheDocument()
+ // Clicking it again puts it away, rather than leaving it open for good.
+ await userEvent.click(option)
+ await waitFor(() => expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument())
+ })
+
it('keeps notes with the question, not in a second notepad floating over it', async () => {
await begin()
const bar = await screen.findByRole('toolbar', { name: 'Question actions' })
@@ -411,7 +435,9 @@ describe('quiz player', () => {
mount()
await userEvent.click(await screen.findByRole('button', { name: 'Start session' }))
await findStem('Full first clinical question.')
- await screen.findByRole('button', { name: 'Make shareable' })
+ // Sharing folded away behind the question bar's more-menu, alongside
+ // saving and giving feedback — occasional actions, not part of every read.
+ await userEvent.click(await screen.findByRole('button', { name: 'More options' }))
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
await screen.findByRole('button', { name: 'Copy share link' })
expect(api.post).toHaveBeenCalledWith('/quizzes/10/share-link')
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css
index 5474768..df1a363 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -108,7 +108,17 @@
.quiz-review-tabs > span:first-child { background: #333; color: white; padding: 11px 24px; font-size: .85rem; font-weight: 600; }
.quiz-review-tabs .quiz-source-page { color: #6f7886; padding: 10px; font-size: .84rem; }
.quiz-player .explanation { border: 0; border-radius: 0; background: transparent; padding: 16px 0; font-size: 1rem; line-height: 1.75; color: #383d43; }
-.quiz-player .quiz-stats-note { color: #7a818c; font-size: .74rem; margin: 10px 0; }
+/* A row of separate things — a note, the clocks, and two or three actions —
+ not a paragraph with controls run into the text. It was a
with inline
+ children, so everything butted together with no space at all. */
+.quiz-player .quiz-stats-note {
+ display: flex; align-items: center; flex-wrap: wrap;
+ gap: 10px 14px; margin: 14px 0;
+ padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px;
+ background: var(--bg); color: #7a818c; font-size: .74rem;
+}
+/* The note takes the room, so the actions sit together on the right. */
+.quiz-player .quiz-stats-note > :first-child { margin-right: auto; }
.quiz-submit-response { margin: 16px 0; }
.quiz-submit-error { padding: 14px; color: #962b3e; background: #fff0f2; margin-bottom: 14px; }
.quiz-review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin: 20px 0; }
@@ -143,7 +153,8 @@
.question-reading ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.question-reading a { color: var(--primary); font-size: .86rem; }
.quiz-option-explanation { display: block; width: 100%; margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: var(--input-bg); border: 1px solid var(--border); font-size: .8rem; color: var(--text); text-align: left; }
-.quiz-stats-toggle { margin-left: 8px; background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: .72rem; color: var(--text-muted); cursor: pointer; }
+.quiz-stats-toggle { background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: .72rem; color: var(--text-muted); cursor: pointer; }
+.quiz-stats-toggle { min-height: 28px; white-space: nowrap; }
.quiz-stats-toggle:hover { color: var(--primary); border-color: var(--primary); }
.quiz-key-points { margin: 6px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.quiz-key-points li { font-size: .86rem; }
@@ -200,7 +211,13 @@
/* Session, this question, and the running average — in study mode too, because
"four minutes on one question" is the number that says whether you are
learning or stuck, countdown or no countdown. */
-.quiz-clock { display: inline-flex; align-items: center; gap: 12px; margin-right: auto; }
+/* The clocks are one group. `margin-right: auto` used to push everything after
+ them to the far edge of the row, which is what left the note welded to the
+ pause button at one end and the actions stranded at the other. */
+.quiz-clock {
+ display: inline-flex; align-items: center; gap: 14px;
+ padding: 2px 12px; border-inline: 1px solid var(--border);
+}
.quiz-clock-pause {
width: 28px; height: 28px; flex-shrink: 0; cursor: pointer;
border: 1px solid var(--border); border-radius: 7px; background: var(--card-bg);
@@ -238,3 +255,25 @@
/* Where the rail is on screen the counter is a label, not a control — there is
nothing left for it to open. */
.quiz-topbar .quiz-question-select.is-static { margin: 0; border: 0; cursor: default; }
+
+/* Occasional session actions, folded away until asked for. */
+.quiz-more { position: relative; }
+.quiz-more-menu {
+ position: absolute; right: 0; top: calc(100% + 6px); z-index: 50;
+ min-width: 250px; padding: 12px 14px;
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
+}
+.quiz-more-menu .quiz-code-badge { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
+
+/* Items inside the more-menu read as a list of actions, not as buttons in a row. */
+.quiz-more-item, .quiz-more-feedback > summary {
+ display: block; width: 100%; padding: 8px 10px; min-height: 38px;
+ font: inherit; font-size: 0.84rem; text-align: left; color: var(--text);
+ background: none; border: 0; border-radius: 7px; cursor: pointer;
+}
+.quiz-more-item:hover, .quiz-more-feedback > summary:hover { background: var(--bg); }
+.quiz-more-feedback[open] > summary { color: var(--primary); font-weight: 600; }
+.quiz-more-feedback { border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
+.quiz-more-feedback .fb-form { padding: 4px 10px 8px; }
+.quiz-more-menu .quiz-code-badge { padding: 8px 10px; }