diff --git a/backend/alembic/versions/n7a8b9c0d142_collections.py b/backend/alembic/versions/n7a8b9c0d142_collections.py new file mode 100644 index 0000000..b6a5bc6 --- /dev/null +++ b/backend/alembic/versions/n7a8b9c0d142_collections.py @@ -0,0 +1,33 @@ +"""Personal question libraries. + +Revision ID: n7a8b9c0d142 +Revises: m6a7b8c9d031 +""" +from alembic import op + +revision = "n7a8b9c0d142" +down_revision = "m6a7b8c9d031" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS user_collections ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )""") + op.execute(""" + CREATE TABLE IF NOT EXISTS user_collection_questions ( + id SERIAL PRIMARY KEY, + collection_id INTEGER NOT NULL REFERENCES user_collections(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + CONSTRAINT uq_collection_question UNIQUE (collection_id, question_id) + )""") + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS user_collection_questions") + op.execute("DROP TABLE IF EXISTS user_collections") diff --git a/backend/app/main.py b/backend/app/main.py index 6d00537..f423cf0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from app.logging_config import setup_logging 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 -from app.routers import study_tools, uploads, articles, comments, share +from app.routers import study_tools, uploads, articles, comments, share, collections from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -619,6 +619,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) app.include_router(comments.router, prefix="/api/comments", tags=["comments"]) app.include_router(share.router, prefix="/api/share", tags=["share"]) +app.include_router(collections.router, prefix="/api/collections", tags=["collections"]) app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cba7335..4eba7fc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,7 @@ from app.models.article import Article, QuestionArticleLink from app.models.comment import Comment from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink from app.models.question_category import QuestionCategory, QuestionCategoryLink +from app.models.collection import UserCollection, UserCollectionQuestion __all__ = [ "User", @@ -38,4 +39,6 @@ __all__ = [ "FlashcardArticleLink", "QuestionCategory", "QuestionCategoryLink", + "UserCollection", + "UserCollectionQuestion", ] diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py new file mode 100644 index 0000000..c669ffd --- /dev/null +++ b/backend/app/models/collection.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint +from app.database import Base + + +class UserCollection(Base): + __tablename__ = "user_collections" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + title = Column(String(200), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class UserCollectionQuestion(Base): + __tablename__ = "user_collection_questions" + __table_args__ = (UniqueConstraint("collection_id", "question_id", name="uq_collection_question"),) + + id = Column(Integer, primary_key=True, index=True) + collection_id = Column(Integer, ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py new file mode 100644 index 0000000..83689d1 --- /dev/null +++ b/backend/app/routers/collections.py @@ -0,0 +1,100 @@ +"""Personal question libraries (saved questions).""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, field_validator +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.collection import UserCollection, UserCollectionQuestion +from app.models.question import Question +from app.models.user import User +from app.services.quiz_builder import bank_question_predicate +from app.utils.auth import get_current_user + +router = APIRouter() + + +class CollectionCreate(BaseModel): + title: str + + @field_validator("title") + @classmethod + def title_shape(cls, value): + value = value.strip() + if not value or len(value) > 200: + raise ValueError("Collection title is required (max 200 characters)") + return value + + +class CollectionQuestionIn(BaseModel): + question_id: int + + +def _own(db, user, collection_id): + collection = db.get(UserCollection, collection_id) + if not collection: + raise HTTPException(404, "Collection not found") + if collection.user_id != user.id: + raise HTTPException(403, "Not your collection") + return collection + + +@router.get("/") +def list_collections(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).order_by( + UserCollection.created_at.desc()).all() + return [{"id": c.id, "title": c.title, "question_count": db.query(UserCollectionQuestion).filter( + UserCollectionQuestion.collection_id == c.id).count()} for c in rows] + + +@router.post("/", status_code=201) +def create_collection(data: CollectionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + collection = UserCollection(user_id=user.id, title=data.title) + db.add(collection) + db.commit() + db.refresh(collection) + return {"id": collection.id, "title": collection.title, "question_count": 0} + + +@router.patch("/{collection_id}") +def rename_collection(collection_id: int, data: CollectionCreate, db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + collection = _own(db, user, collection_id) + collection.title = data.title + db.commit() + return {"id": collection.id, "title": collection.title} + + +@router.delete("/{collection_id}", status_code=204) +def delete_collection(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + db.delete(_own(db, user, collection_id)) + db.commit() + + +@router.get("/{collection_id}/questions") +def collection_questions(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + collection = _own(db, user, collection_id) + rows = db.query(Question).join(UserCollectionQuestion, UserCollectionQuestion.question_id == Question.id).filter( + UserCollectionQuestion.collection_id == collection.id).all() + return [{"id": q.id, "question_text": q.question_text} for q in rows] + + +@router.put("/{collection_id}/questions/{question_id}") +def add_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + collection = _own(db, user, collection_id) + if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(user)).first(): + raise HTTPException(404, "Question not found") + if db.query(UserCollectionQuestion.id).filter_by(collection_id=collection.id, question_id=question_id).first(): + return {"added": False} + db.add(UserCollectionQuestion(collection_id=collection.id, question_id=question_id)) + db.commit() + return {"added": True} + + +@router.delete("/{collection_id}/questions/{question_id}", status_code=204) +def remove_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + _own(db, user, collection_id) + db.query(UserCollectionQuestion).filter_by(collection_id=collection_id, question_id=question_id).delete( + synchronize_session=False) + db.commit() diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py index d8c59c4..e708304 100644 --- a/backend/app/routers/question_categories.py +++ b/backend/app/routers/question_categories.py @@ -1,7 +1,7 @@ """Question category management; saved quiz membership is never changed here.""" from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from sqlalchemy import or_, select +from sqlalchemy import or_, select, func from sqlalchemy.orm import Session from app.database import get_db @@ -9,7 +9,7 @@ from app.models.question import Question from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.user import User from app.services.quiz_builder import (bank_query, filtered_bank_query, category_descendants, category_breadcrumbs, - validate_parent, GenerateTestRequest, generate_test) + validate_parent, GenerateTestRequest, generate_test, bank_question_predicate) from app.utils.auth import get_current_user, require_moderator router = APIRouter() @@ -32,21 +32,50 @@ class QCatResponse(BaseModel): def category_response(db, user, cat, cats): ids = category_descendants(cats, [cat.id]) - extra = select(QuestionCategoryLink.question_id).where(QuestionCategoryLink.category_id.in_(ids)) - count = bank_query(db, user).filter(or_( - Question.question_category_id.in_(ids), - Question.id.in_(extra), - )).count() return QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id, - breadcrumbs=category_breadcrumbs(cats, cat.id), - question_count=count) + breadcrumbs=category_breadcrumbs(cats, cat.id), question_count=0) + + +def _category_totals(db, user, by_id): + """Distinct question totals per category via ancestor set unions (fast, no N+1).""" + from collections import defaultdict + rows = bank_query(db, user).with_entities(Question.id, Question.question_category_id).all() + qids = [row[0] for row in rows] + direct = defaultdict(set) + for qid, cid in rows: + if cid is not None: + direct[cid].add(qid) + if qids: + for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter( + QuestionCategoryLink.question_id.in_(qids)).all(): + direct[cid].add(qid) + ancestors = {} + for cat_id in by_id: + path, seen = [cat_id], set() + node = by_id[cat_id] + while node.parent_id and node.parent_id in by_id and node.parent_id not in seen: + seen.add(node.parent_id) + path.append(node.parent_id) + node = by_id[node.parent_id] + ancestors[cat_id] = path + totals = defaultdict(set) + for cid, question_set in direct.items(): + for ancestor in ancestors.get(cid, [cid]): + totals[ancestor] |= question_set + return {cid: len(question_set) for cid, question_set in totals.items()} @router.get("/", response_model=list[QCatResponse]) def list_question_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): cats = db.query(QuestionCategory).order_by(QuestionCategory.name).all() - # ponytail: one count per category; aggregate counts if the taxonomy becomes large. - return [category_response(db, current_user, cat, cats) for cat in cats] + by_id = {cat.id: cat for cat in cats} + totals = _category_totals(db, current_user, by_id) + return [ + QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id, + breadcrumbs=category_breadcrumbs(cats, cat.id), + question_count=totals.get(cat.id, 0)) + for cat in cats + ] def validate_category(db, data, cat_id=None): diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index 12cc6eb..bf94ec6 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -234,6 +234,8 @@ def get_question_bank( uncategorized: bool = Query(False), favorites_only: bool = Query(False), my_questions: bool = Query(False, description="Show only questions created by current user"), + difficulty: Literal["easy", "medium", "hard"] | None = Query(None), + article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"), tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"), search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid" limit: int = Query(50, le=200), @@ -246,6 +248,15 @@ def get_question_bank( if my_questions: query = query.filter(Question.user_id == current_user.id) + if difficulty: + query = query.filter(Question.difficulty == difficulty) + if article_ids: + article_list = [int(part) for part in article_ids.split(",") if part.strip().isdigit()] + if article_list: + from app.models.article import QuestionArticleLink + query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where( + QuestionArticleLink.article_id.in_(article_list)))) + if quiz_id: query = query.filter(Question.source_quiz_id == quiz_id) @@ -504,10 +515,14 @@ def count_builder_questions( state: Literal["all", "unused", "incorrect", "bookmarked"] = "all", is_shared: bool = False, difficulty: Literal["easy", "medium", "hard"] | None = Query(None), + article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"), + tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty).count()} + ids = [int(part) for part in (article_ids or "").split(",") if part.strip().isdigit()] + tag_list = [int(part) for part in (tag_ids or "").split(",") if part.strip().isdigit()] + return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty, ids, tag_list).count()} @router.post("/builder") diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index 91d274f..1a292be 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -66,10 +66,27 @@ def bank_query(db, user): return db.query(Question).filter(bank_question_predicate(user)) -def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None): +def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=()): query = bank_query(db, user) if difficulty: query = query.filter(Question.difficulty == difficulty) + if article_ids: + from app.models.article import QuestionArticleLink + query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where( + QuestionArticleLink.article_id.in_(article_ids)))) + if tag_ids: + from sqlalchemy import text as sa_text + tag_list = list(dict.fromkeys(tag_ids)) + matching = list(db.execute(sa_text(""" + SELECT question_id FROM question_tag_links + WHERE tag_id = ANY(:tag_ids) + GROUP BY question_id + HAVING COUNT(DISTINCT tag_id) = :cnt + """), {"tag_ids": tag_list, "cnt": len(tag_list)}).scalars()) + if matching: + query = query.filter(Question.id.in_(matching)) + else: + query = query.filter(Question.id.is_(None)) # No questions match all tags. if category_ids: ids = category_descendants(db.query(QuestionCategory).all(), category_ids) query = query.filter(or_( @@ -127,6 +144,9 @@ class GenerateTestRequest(TestOptions): expected_count: int | None = Field(default=None, ge=0) difficulty: Literal["easy", "medium", "hard"] | None = None algorithm: Literal["random", "adaptive"] = "random" + article_ids: list[int] = Field(default_factory=list) + tag_ids: list[int] = Field(default_factory=list) + explicit_ids: list[int] = Field(default_factory=list) def create_saved_test(db, user, data, question_ids): @@ -203,9 +223,9 @@ def generate_test(db, user, data): if len(ids) < data.count: raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}") return create_saved_test(db, user, data, ids) - query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared) - if data.difficulty: - query = query.filter(Question.difficulty == data.difficulty) + query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared, data.difficulty, data.article_ids, data.tag_ids) + if data.explicit_ids: + query = query.filter(Question.id.in_(list(dict.fromkeys(data.explicit_ids)))) ids = [row[0] for row in query.with_entities(Question.id).all()] if data.expected_count is not None and data.expected_count != len(ids): raise HTTPException(409, "Available count changed. Refresh the count and try again") diff --git a/docs/quiz-revamp-plan.md b/docs/quiz-revamp-plan.md index f4a36de..b47e250 100644 --- a/docs/quiz-revamp-plan.md +++ b/docs/quiz-revamp-plan.md @@ -70,3 +70,16 @@ Run frontend build and targeted/full relevant suites; obtain independent review, - Lab-reference support includes educator-managed entries and source/age/unit fields. Do not publish unsourced or AI-invented ranges. An honest empty state is permitted until verified clinical data is supplied. - The user has authorized implementation. Continue each completed milestone into verification, review, Git synchronization and the next milestone; stop only for a genuine blocker. - No migrations or deployments were performed during the initial scoping/Git synchronization. Subsequent implementation and validation evidence must be recorded in focused commits. + +## Pending backlog (2026-09-09) + +1. Category management page (with search): organize parents/subparents, create new parent, delete with move_to, move questions between categories; link from bank/create pages. +2. Question editor: use shared CategoryTree facets (primary select + checkbox tree) — in progress. +3. Create Custom Test facets: Status / Difficulty / Systems / Articles sections — code in progress; update tests (state is now buttons, not select). +4. Upload flow: document → matched BANK questions via keywords/embeddings; min 10MB, max 30 questions, 5-day auto-delete. +5. AI "describe what to study" → quiz from matched bank questions (all logged-in users). +6. Quiz action buttons: analysis / rename / repeat / delete. +7. Study recommendations from weakest categories. +8. Dedicated question-management CMS page with per-category educator grants. +9. Vision review finding: question-card row consistency (Study/Share alignment, answer-chip widths). +10. Runner header spacing polish (question review / X of Y) — CSS added, verify visually. diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx index 9a86436..1155320 100644 --- a/frontend/src/components/CategoryPerformance.jsx +++ b/frontend/src/components/CategoryPerformance.jsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' export default function CategoryPerformance() { const [data, setData] = useState(null) const [showAll, setShowAll] = useState(false) + const [adaptiveCount, setAdaptiveCount] = useState(10) + const navigate = useNavigate() useEffect(() => { api.get('/study-tools/performance-by-category') .then(res => setData(res.data)) @@ -42,19 +45,42 @@ export default function CategoryPerformance() {

Performance by category

{data.basis}

- {!showAll && main.map(renderRow)} +
+ + + {main.length > 0 && ( + + )} +
+ {main.map(renderRow)} {showAll && ( -
- {main.map(row => ( -
- {renderRow(row)} - {renderBranch(row.category_id, 1)} +
+
+
+

All categories

+ +
+
+ {main.map(row => ( +
+ {renderRow(row)} + {renderBranch(row.category_id, 1)} +
+ ))}
- ))} +
)}
diff --git a/frontend/src/components/CategoryPerformance.test.jsx b/frontend/src/components/CategoryPerformance.test.jsx index af661b7..a16f0ae 100644 --- a/frontend/src/components/CategoryPerformance.test.jsx +++ b/frontend/src/components/CategoryPerformance.test.jsx @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' import CategoryPerformance from './CategoryPerformance' import api from '../api/client' @@ -13,22 +15,30 @@ describe('category performance', () => { total_answered: 3, basis: 'Your completed, non-expired general test answers.', categories: [ - { category_id: 2, name: 'Clinical reasoning', answered: 2, correct: 1, accuracy: 50 }, - { category_id: 1, name: 'Pediatrics', answered: 1, correct: 1, accuracy: 100 }, + { category_id: 2, name: 'Clinical reasoning', parent_id: null, answered: 2, correct: 1, accuracy: 50 }, + { category_id: 1, name: 'Pediatrics', parent_id: null, answered: 1, correct: 1, accuracy: 100 }, + { category_id: 3, name: 'Neonatal', parent_id: 1, answered: 1, correct: 0, accuracy: 0 }, ], } }) - render() + render() expect(await screen.findByTestId('category-performance')).toBeInTheDocument() expect(screen.getByText('Clinical reasoning')).toBeInTheDocument() expect(screen.getByText('50%')).toBeInTheDocument() expect(screen.getByText('1/2')).toBeInTheDocument() expect(screen.getByText('100%')).toBeInTheDocument() expect(screen.getByText(/completed, non-expired/)).toBeInTheDocument() + // Child rows stay out of the compact view until the side panel opens. + expect(screen.queryByText('Neonatal')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Show all (3)' })) + const panel = await screen.findByRole('dialog', { name: 'All category performance' }) + expect(within(panel).getByText('Neonatal')).toBeInTheDocument() + await userEvent.click(within(panel).getByRole('button', { name: 'Close all categories' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('renders nothing without category data', async () => { api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } }) - const { container } = render() + const { container } = render() await waitFor(() => expect(api.get).toHaveBeenCalled()) await new Promise(resolve => setTimeout(resolve, 0)) expect(container.querySelector('[data-testid="category-performance"]')).toBeNull() diff --git a/frontend/src/components/CategoryTree.jsx b/frontend/src/components/CategoryTree.jsx new file mode 100644 index 0000000..9385270 --- /dev/null +++ b/frontend/src/components/CategoryTree.jsx @@ -0,0 +1,55 @@ +import { useState } from 'react' + +export default function CategoryTree({ categories, selectedIds, onToggle, excludedId = null, searchable = false }) { + const [query, setQuery] = useState('') + const childrenOf = {} + for (const cat of categories) { + ;(childrenOf[cat.parent_id || 0] ||= []).push(cat) + } + const visible = searchable + ? categories.filter(cat => [cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(query.toLowerCase())) + : categories + const descendantSelected = (cat) => { + const ids = [] + const walk = (id) => { for (const child of childrenOf[id] || []) { ids.push(child.id); walk(child.id) } } + walk(cat.id) + return ids.some(id => selectedIds.includes(id)) + } + const renderBranch = (parentId) => { + const branch = (childrenOf[parentId] || []).filter(cat => visible.includes(cat)) + if (!branch.length) return null + return
    + {branch.map(cat => { + const excluded = cat.id === excludedId + const node = ( + + ) + const kids = (childrenOf[cat.id] || []).filter(child => visible.includes(child)) + if (!kids.length) return
  • {node}
  • + return
  • +
    + + {renderBranch(cat.id)} +
    +
  • + })} +
+ } + return ( +
+ {searchable && ( +
+ + setQuery(e.target.value)} + placeholder="Search topics…" aria-label="Search topics" className="input" /> +
+ )} + {renderBranch(0)} +
+ ) +} diff --git a/frontend/src/components/CommentSection.jsx b/frontend/src/components/CommentSection.jsx index 7e94f69..2143cb8 100644 --- a/frontend/src/components/CommentSection.jsx +++ b/frontend/src/components/CommentSection.jsx @@ -18,7 +18,7 @@ export default function CommentSection({ articleId, questionId }) { const load = useCallback(async (off = 0) => { try { - const res = await api.get('/comments', { params: { ...params, limit: LIMIT, offset: off } }) + 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) @@ -33,7 +33,7 @@ export default function CommentSection({ articleId, questionId }) { setError('') setSubmitting(true) try { - const res = await api.post('/comments', { ...params, content: draft }) + 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('') diff --git a/frontend/src/components/CommentSection.test.jsx b/frontend/src/components/CommentSection.test.jsx index 7524024..ee544d7 100644 --- a/frontend/src/components/CommentSection.test.jsx +++ b/frontend/src/components/CommentSection.test.jsx @@ -31,7 +31,7 @@ describe('comment section', () => { 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' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments/', { question_id: 5, content: 'New note' })) expect(await screen.findByText('New note')).toBeInTheDocument() }) diff --git a/frontend/src/index.css b/frontend/src/index.css index b33406e..4693e49 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -746,3 +746,17 @@ body { .category-performance-row { grid-template-columns: 1fr 56px; } .cp-track { grid-column: 1 / -1; grid-row: 2; } } +.cp-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } +.cp-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; } +.cp-panel { background: var(--card-bg); width: min(520px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); } +.cp-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); } +.cp-panel-header h2 { margin: 0; font-size: 1rem; } +.cp-panel-header button { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-muted); } +.cp-panel-body { flex: 1; overflow-y: auto; padding: 14px 18px; } +@media (max-width: 640px) { + .cp-overlay { align-items: flex-end; } + .cp-panel { width: 100%; height: 85vh; border-radius: 16px 16px 0 0; } +} +.cp-adaptive-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); } +.cp-adaptive-label { display: flex; align-items: center; gap: 6px; font-size: .82rem; } +.cp-adaptive-label select { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; } diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index d99b22f..1575e63 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -49,7 +49,7 @@ export default function ArticlesPage() { const params = {} if (categoryId) params.category_id = categoryId if (query.trim()) params.q = query.trim() - api.get('/articles', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false)) + api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false)) }, [categoryId, query]) useEffect(() => { load() }, [load]) @@ -61,7 +61,7 @@ export default function ArticlesPage() { setError('') if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return } try { - const res = await api.post('/articles', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] }) + const res = await api.post('/articles/', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] }) setShowCreate(false) navigate(`/articles/${res.data.id}?edit=1`) } catch (err) { diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index f14bdc3..be6a42f 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -22,7 +22,7 @@ beforeEach(() => { vi.resetAllMocks() api.get.mockImplementation(url => { if (url === '/question-categories') return Promise.resolve({ data: [] }) - if (url === '/articles') return Promise.resolve({ data: [article] }) + if (url === '/articles/') return Promise.resolve({ data: [article] }) if (url === '/articles/linked') return Promise.resolve({ data: [article] }) if (url === '/articles/1') return Promise.resolve({ data: article }) if (url === '/articles/1/questions') return Promise.resolve({ data: [{ question_id: 1, question_text: 'Linked question text', correct_answer: 'yes', explanation: 'Why', section_id: null }] }) diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 22b40e1..34f23af 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -52,3 +52,6 @@ details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45d .custom-test-actions .btn-primary { flex: 1; background: var(--primary); color: var(--primary-fg); font-weight: 600; padding: 10px 16px; } .custom-test-main > p { margin: 8px 0 0; } .custom-test-main > .custom-test-share { margin-top: 8px; } +.custom-test-tags { display: flex; flex-direction: column; gap: 2px; max-height: 24vh; overflow-y: auto; } +.custom-test-tags label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; } +.custom-test-exam { display: flex; gap: 6px; align-items: center; font-size: .84rem; font-weight: 600; color: var(--primary); margin: 4px 0; } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 3cd2669..f62a660 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -12,12 +12,18 @@ export default function CustomQuizPage() { const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))]) const [state, setState] = useState('all') const [shared, setShared] = useState(false) - const [title, setTitle] = useState(() => `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`) + const [title, setTitle] = useState(() => { + const now = new Date() + return `Custom test from ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${now.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}` + }) const [mode, setMode] = useState('learning') const [time, setTime] = useState('') - const [count, setCount] = useState(20) + const [count, setCount] = useState(() => { + const fromUrl = parseInt(searchParams.get('count'), 10) + return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20 + }) const [difficulty, setDifficulty] = useState('') - const [adaptive, setAdaptive] = useState(false) + const [adaptive, setAdaptive] = useState(() => searchParams.get('adaptive') === '1') const [available, setAvailable] = useState(null) const [countKey, setCountKey] = useState(null) const [error, setError] = useState('') @@ -40,6 +46,8 @@ export default function CustomQuizPage() { const params = new URLSearchParams({ state, is_shared: String(shared) }) if (difficulty) params.append('difficulty', difficulty) categoryIds.forEach(id => params.append('category_ids', id)) + articleIds.forEach(id => params.append('article_ids', id)) + tagIds.forEach(id => params.append('tag_ids', id)) api.get('/questions/builder/count', { params }).then(r => { if (active) { setAvailable(r.data.count); setCountKey(filterKey) } }).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') }) @@ -58,6 +66,7 @@ export default function CustomQuizPage() { title: title.trim(), category_ids: categoryIds, state, count: Number(count), expected_count: available, mode, time_limit_minutes: mode === 'timed' && time ? Number(time) : null, is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random', + article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds, }) navigate(`/quizzes/${result.data.id}`) } catch (err) { @@ -69,6 +78,26 @@ export default function CustomQuizPage() { const [catSearch, setCatSearch] = useState('') const [filtersOpen, setFiltersOpen] = useState(true) + const [articleIds, setArticleIds] = useState([]) + const [tagIds, setTagIds] = useState([]) + const [articles, setArticles] = useState([]) + const [tags, setTags] = useState({ subjects: [], keywords: [] }) + const [presetIds, setPresetIds] = useState([]) + const [collections, setCollections] = useState([]) + useEffect(() => { + api.get('/articles/').then(res => setArticles(Array.isArray(res.data) ? res.data : [])).catch(() => setArticles([])) + api.get('/tags').then(res => setTags(res.data && res.data.subjects ? res.data : { subjects: [], keywords: [] })).catch(() => {}) + api.get('/collections/').then(res => setCollections(Array.isArray(res.data) ? res.data : [])).catch(() => setCollections([])) + }, []) + const togglePreset = async (collection) => { + if (presetIds.includes(collection.id)) { setPresetIds(ids => ids.filter(id => id !== collection.id)); return } + setPresetIds(ids => [...ids, collection.id]) + if (!collection._loaded) { + const res = await api.get(`/collections/${collection.id}/questions`) + setCollections(prev => prev.map(c => c.id === collection.id ? { ...c, _loaded: true, question_ids: res.data.map(q => q.id) } : c)) + } + } + const explicitIds = [...new Set(collections.filter(c => presetIds.includes(c.id)).flatMap(c => c.question_ids || []))] const childrenOf = {} for (const cat of categories) { ;(childrenOf[cat.parent_id || 0] ||= []).push(cat) @@ -114,7 +143,42 @@ export default function CustomQuizPage() {
-

Topics

+

Filters

+

Status

+
+ {[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => ( + + ))} +
+

Difficulty

+ +

Exams

+ +

Disciplines

+
+ {(tags.subjects || []).slice(0, 40).map(tag => ( + + ))} +
+

Symptoms & keywords

+
+ {(tags.keywords || []).slice(0, 40).map(tag => ( + + ))} +
+

Systems

Parent categories include all their subcategories.

@@ -123,19 +187,32 @@ export default function CustomQuizPage() {
{renderTree(0)} +

Saved

+
+ + {collections.map(collection => ( + + ))} +
+

Articles

+
+ {(articles || []).map(article => ( + + ))} + {(articles || []).length === 0 &&

No articles yet.

} +
- - {adaptive &&

Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.

} diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index d3ebcd1..8f54a8c 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -13,7 +13,11 @@ const categories = [ { id: 2, name: 'Neonatal', question_count: 10, breadcrumbs: [{ id: 1, name: 'Pediatrics' }, { id: 2, name: 'Neonatal' }] }, ] function setupCount(count = 30) { - api.get.mockImplementation(url => Promise.resolve({ data: url === '/question-categories/' ? categories : { count } })) + api.get.mockImplementation(url => { + if (url === '/question-categories/') return Promise.resolve({ data: categories }) + if (url.startsWith('/articles') || url.startsWith('/collections') || url.startsWith('/tags')) return Promise.resolve({ data: url.startsWith('/articles') ? [] : url.startsWith('/collections') ? [] : { subjects: [], keywords: [] } }) + return Promise.resolve({ data: { count } }) + }) } function renderBuilder() { render( @@ -34,7 +38,7 @@ describe('CustomQuizPage', () => { expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() await userEvent.click(screen.getByLabelText('Pediatrics (30)')) await userEvent.click(screen.getByLabelText('Neonatal (10)')) - await userEvent.selectOptions(screen.getByLabelText('Question state'), 'incorrect') + await userEvent.click(screen.getByRole('button', { name: 'Unused' })) await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed') fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } }) fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } }) @@ -43,13 +47,13 @@ describe('CustomQuizPage', () => { const calls = api.get.mock.calls.filter(([url]) => url === '/questions/builder/count') const params = calls.at(-1)[1].params expect(params.getAll('category_ids')).toEqual(['1', '2']) - expect(params.get('state')).toBe('incorrect') + expect(params.get('state')).toBe('unused') expect(params.get('is_shared')).toBe('true') await userEvent.click(screen.getByRole('button', { name: 'Create Test' })) expect(api.post).toHaveBeenCalledWith('/questions/builder', { - title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, category_ids: [1, 2], state: 'incorrect', count: 10, + title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${new Date().toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`, category_ids: [1, 2], state: 'unused', count: 10, mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true, - difficulty: null, algorithm: 'random', + difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], explicit_ids: [], }) await screen.findByRole('heading', { name: 'Saved test' }) }) @@ -92,7 +96,7 @@ describe('CustomQuizPage', () => { await screen.findByLabelText('Pediatrics (30)') const old = resolveOld setupCount(4) - await userEvent.selectOptions(screen.getByLabelText('Question state'), 'bookmarked') + await userEvent.click(screen.getByRole('button', { name: 'Saved' })) await screen.findByText('4 questions available') old({ data: { count: 100 } }) await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument()) diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css index df82ed9..dfea4ee 100644 --- a/frontend/src/pages/QuestionBankPage.css +++ b/frontend/src/pages/QuestionBankPage.css @@ -21,3 +21,16 @@ .bank-layout { grid-template-columns: 1fr; } .bank-category-list { max-height: none; } } +.category-tree-wrap { margin-top: 6px; } +.category-tree { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; } +.category-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; } +.category-tree li { margin: 1px 0; } +.category-tree label { display: flex; align-items: baseline; gap: 6px; font-size: .84rem; cursor: pointer; } +.category-tree-count { color: var(--text-muted); font-size: .74rem; } +.category-tree-excluded { opacity: .5; } +.category-tree-branch { display: flex; align-items: center; cursor: pointer; list-style: none; } +.category-tree-branch::-webkit-details-marker { display: none; } +.category-tree-chevron { display: inline-block; width: 7px; height: 7px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 6px; transition: transform .15s ease; flex-shrink: 0; } +details[open] > .category-tree-branch .category-tree-chevron { transform: rotate(45deg); } +.bank-articles { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; max-height: 30vh; overflow-y: auto; } +.bank-articles label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; } diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 5fa7551..404b73d 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -3,6 +3,7 @@ import { useNavigate, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import Dialog from '../components/Dialog' +import CategoryTree from '../components/CategoryTree' import { useDialog } from '../hooks/useDialog' const TeachChat = lazy(() => import('../components/TeachChat')) @@ -22,7 +23,7 @@ function stripHtml(html) { return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim() } -function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }) { +function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) { const [answered, setAnswered] = useState(null) return ( <> @@ -105,6 +106,27 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
)} +
+ Add to library +
+ + { + 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' }} /> +
+
{answered && (
@@ -323,7 +345,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) { return (
e.target === e.currentTarget && onClose()}> -
+

Edit Question

@@ -368,28 +390,20 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
)}
- - setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category"> {categories.map(c => )} -
-
- -

- A question can appear in several categories; the primary category stays the main one. -

-
- {categories.filter(c => c.id !== (form.question_category_id ? parseInt(form.question_category_id) : null)).map(c => { - const checked = form.extraCategoryIds.includes(c.id) - return ( - - ) - })} -
+
@@ -650,6 +664,13 @@ export default function QuestionBankPage() { const [categories, setCategories] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [difficulty, setDifficulty] = useState('') + 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 [searchMode, setSearchMode] = useState('hybrid') const [filterCatIds, setFilterCatIds] = useState([]) const [showUncategorized, setShowUncategorized] = useState(false) @@ -716,7 +737,7 @@ export default function QuestionBankPage() { clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300) return () => clearTimeout(debounceRef.current) - }, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty]) + }, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')]) const toggleTag = (tagId) => { setSelectedTagIds(prev => { @@ -739,6 +760,7 @@ export default function QuestionBankPage() { 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 @@ -916,7 +938,7 @@ export default function QuestionBankPage() { ) })()} - {studyQuestion && setStudyQuestion(null)} + {studyQuestion && setStudyQuestion(null)} isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />} {editQuestion && setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))} @@ -1033,6 +1055,17 @@ export default function QuestionBankPage() { +

Articles

+
+ {articles.map(article => ( + + ))} + {articles.length === 0 &&

No articles yet.

} +

Categories

{categories.map(cat => { diff --git a/frontend/src/pages/QuestionBankPage.test.jsx b/frontend/src/pages/QuestionBankPage.test.jsx index 4bdb8e0..7d2a7c1 100644 --- a/frontend/src/pages/QuestionBankPage.test.jsx +++ b/frontend/src/pages/QuestionBankPage.test.jsx @@ -217,12 +217,11 @@ describe('QuestionBankPage edit modal multi-category', () => { api.patch = vi.fn().mockResolvedValue({ data: {} }) renderPage() await userEvent.click(await screen.findByRole('button', { name: 'Edit' })) - expect(await screen.findByRole('group', { name: 'Additional subcategories' })).toBeInTheDocument() - const extras = screen.getByRole('group', { name: 'Additional subcategories' }) - expect(within(extras).getByLabelText('Cardiology')).toBeChecked() - expect(within(extras).getByLabelText('Renal')).not.toBeChecked() - expect(within(extras).queryByLabelText('Neonatology')).not.toBeInTheDocument() - await userEvent.click(within(extras).getByLabelText('Renal')) + const modal = await screen.findByRole('dialog', { name: 'Edit Question' }) + expect(within(modal).getByLabelText(/Cardiology/)).toBeChecked() + expect(within(modal).getByLabelText(/Renal/)).not.toBeChecked() + expect(within(modal).getByLabelText(/Neonatology/)).toBeDisabled() + await userEvent.click(within(modal).getByLabelText(/Renal/)) await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })) await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({ additional_category_ids: [2, 3], diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index c39e4e4..3e5870f 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -423,6 +423,7 @@ export default function QuizPage() { const [resumedExpired, setResumedExpired] = useState(false) const [resumeRetry, setResumeRetry] = useState(0) const [progressError, setProgressError] = useState('') + const [restartConfirm, setRestartConfirm] = useState(false) const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) @@ -637,7 +638,7 @@ export default function QuizPage() { return () => clearInterval(timerRef.current) }, [id, resumeRetry]) - const startAttempt = async (mode, voice, timerMinutes = null) => { + const startAttempt = async (mode, voice, timerMinutes = null, fresh = false) => { hasStarted.current = true setSelectedVoice(voice) setStarting(true) @@ -646,7 +647,7 @@ export default function QuizPage() { try { // Start attempt first (may select random question subset) - const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}`) + const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}${fresh ? '&fresh=true' : ''}`) mode = attemptRes.data.mode || mode setAttemptId(attemptRes.data.id) const aid = attemptRes.data.id @@ -1044,6 +1045,21 @@ const timerStarted = timeLeft !== null + {restartConfirm ? ( + Restart from the beginning? + + + + ) : ( + + )} {isModerator && ✏️ Edit}
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index eaced57..c17ee8f 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -92,3 +92,4 @@ .quiz-header-card { padding-bottom: 12px; } .quiz-response-stat { font-size: .7rem; } } +.quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; }