Decouple course quizzes, replace passlib, add security hardening

- Fully decouple course quizzes from main quiz system (hidden from
  dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-06 21:31:05 +02:00
parent bc1a88a954
commit 8137d5b20c
37 changed files with 2574 additions and 509 deletions

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ dist/
.DS_Store
*.swp
backend/.env.save
# Database backups
backups/

View file

@ -14,7 +14,7 @@ PedsHub is a pediatric medical learning platform with an integrated LMS. Admins
## Stack
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL 16 (pgvector) + Redis + Celery
- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Nginx
- **Frontend**: React 18 + Vite + React Router 6 + plain CSS + Milkdown (markdown WYSIWYG) + Nginx
- **AI**: LiteLLM proxy routes to Claude/GPT/Gemini/Bedrock. `_proxy_model()` in ai_service.py adds `openai/` prefix for the proxy.
- **Vectors**: ChromaDB for document page chunks (RAG), pgvector for question embeddings (semantic search)
- **Config**: Backend reads `.env` via pydantic-settings. Frontend uses runtime `window.__APP_CONFIG__` injected by `docker-entrypoint.sh` (not Vite build-time env).
@ -26,6 +26,7 @@ Browser → Nginx (frontend) → FastAPI (4 uvicorn workers)
├── ChromaDB (document page chunks for extraction context)
├── Redis (Celery broker, rate limits, settings, job progress, session locks)
└── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration)
└── db-backup (daily PostgreSQL dumps, 14 daily / 4 weekly / 6 monthly retention, ./backups/)
## Course/LMS system
Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons.
@ -36,8 +37,11 @@ Any user can create courses (not just moderators). Course structure: Course →
- **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate`
- **Status**: draft (creator-only) → published (visible to all) → archived
- **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder)
- **Question bank integration**: quiz lessons select questions from bank by category/tags, creates a quiz via `POST /questions/from-bank`
- **Any user can create quizzes** from the question bank (not just moderators). User quizzes are private by default (`is_published=0`, `is_shared=0`)
- **Course quizzes**: fully decoupled from main quiz system. `POST /courses/{id}/quiz` copies questions and creates an independent quiz with `course_id` set. Hidden from main quizzes page, search, dashboard stats, and attempt history. Creator sets mode (timed/study), time limit, max attempts, questions per attempt (random pool), and `allow_review` (whether students can review answers). Results page is course-aware — shows "Back to Course" instead of retake/all quizzes/delete. Users see attempt history + review links on the course page only.
- **User roles**: `admin`, `moderator`, `user`. Only moderators and admins can create courses. Any user can create quizzes from the question bank.
- **Enrollee analytics**: `GET /courses/{id}/enrollees` returns progress + quiz scores. `GET /courses/{id}/enrollees/export` exports CSV.
- **Question ownership**: questions have `user_id` and `is_shared`. Users see shared + own questions. `PATCH /questions/{id}/share` toggles visibility.
- **Rich editor**: Milkdown (ProseMirror-based markdown WYSIWYG) for lesson content. Supports GFM tables, code blocks, LaTeX math (`$formula$`). No JSX parsing issues with `<` or `{`.
```
## Key directories
@ -74,6 +78,7 @@ frontend/src/
components/
Navbar.jsx — Auth-aware nav with jobs badge
TeachChat.jsx — AI tutor drawer (lazy loaded, markdown/GFM tables)
RichEditor.jsx — Milkdown markdown WYSIWYG editor with toolbar, GFM tables, math, history
```
## Database tables (key ones)
@ -82,8 +87,8 @@ frontend/src/
| users | Accounts with role (admin/moderator/user) | — |
| pdf_documents | Uploaded PDFs | user_id → users |
| sections | Page ranges within a document | document_id → pdf_documents |
| quizzes | Quiz metadata | section_id → sections (nullable), user_id → users |
| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable) |
| quizzes | Quiz metadata (course_id set = course-only, allow_review controls student access) | section_id → sections (nullable), user_id → users, course_id → courses (nullable) |
| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable), user_id → users (nullable) |
| quiz_question_links | Quiz ↔ Question many-to-many | quiz_id, question_id |
| flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users |
| flashcards | Individual cards (front/back) | deck_id → flashcard_decks |
@ -112,3 +117,9 @@ frontend/src/
- Don't use `[someValue === null]` as a useEffect dependency — it evaluates to a constant boolean
- Don't `docker compose restart` expecting code changes to apply — must rebuild
- Don't use `window.confirm()` — user hates browser popups, use inline confirmation or the Dialog component
- Don't use MDXEditor — it's an MDX parser that chokes on `<` and `{` in medical content. Milkdown (CommonMark) is used instead.
- Don't create quizzes via `POST /questions/from-bank` for courses — use `POST /courses/{id}/quiz` which copies questions and hides the quiz from the main page.
- Don't show course quiz data on the main quizzes page, dashboard stats, or attempt history — course quizzes are fully decoupled. Filter with `Quiz.course_id.is_(None)`.
- Don't show "from pool of N" to users on course quiz display — just show the number of questions per attempt.
- Don't allow users to delete course quiz attempts — the backend returns 403.
- Don't put documents listing on the dashboard — it's in Settings page under Nextcloud.

264
backend/app/cli.py Normal file
View file

@ -0,0 +1,264 @@
"""CLI management commands. Run inside the backend container:
docker compose exec backend python -m app.cli <command> [args]
Commands:
list-users List all users
reset-password <email> <password> Reset a user's password
set-role <email> <role> Change user role (admin/moderator/user)
verify-email <email> Force-verify a user's email
delete-user <email> Delete a user and their data
stats Show platform statistics
list-courses List all courses with status
list-quizzes List quizzes (--all includes deleted)
cleanup-redis Clear expired/orphaned Redis keys
export-users Export users to CSV
"""
import sys
import csv
import io
from app.database import SessionLocal
from app.models.user import User
from app.utils.auth import get_password_hash
def reset_password(email: str, new_password: str):
db = SessionLocal()
try:
user = db.query(User).filter(User.email == email.lower().strip()).first()
if not user:
print(f"Error: No user found with email '{email}'")
return False
if len(new_password) < 6:
print("Error: Password must be at least 6 characters")
return False
user.hashed_password = get_password_hash(new_password)
db.commit()
print(f"Password reset for {user.name} ({user.email}) — role: {user.role}")
return True
finally:
db.close()
def list_users():
db = SessionLocal()
try:
users = db.query(User).order_by(User.id).all()
print(f"{'ID':<5} {'Name':<25} {'Email':<35} {'Role':<12}")
print("-" * 77)
for u in users:
print(f"{u.id:<5} {(u.name or '-'):<25} {u.email:<35} {u.role:<12}")
print(f"\n{len(users)} user(s)")
finally:
db.close()
def set_role(email: str, role: str):
if role not in ("admin", "moderator", "user"):
print(f"Error: Invalid role '{role}'. Must be: admin, moderator, user")
return False
db = SessionLocal()
try:
user = db.query(User).filter(User.email == email.lower().strip()).first()
if not user:
print(f"Error: No user found with email '{email}'")
return False
old_role = user.role
user.role = role
db.commit()
print(f"{user.name} ({user.email}): {old_role} -> {role}")
return True
finally:
db.close()
def verify_email(email: str):
from datetime import datetime
from app.models.email_verification import EmailVerification
db = SessionLocal()
try:
user = db.query(User).filter(User.email == email.lower().strip()).first()
if not user:
print(f"Error: No user found with email '{email}'")
return False
v = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if not v:
print(f"{user.email} has no verification record (already verified or legacy user)")
return True
if v.verified_at:
print(f"{user.email} is already verified (at {v.verified_at})")
return True
v.verified_at = datetime.utcnow()
db.commit()
print(f"Email verified for {user.name} ({user.email})")
return True
finally:
db.close()
def delete_user(email: str):
db = SessionLocal()
try:
user = db.query(User).filter(User.email == email.lower().strip()).first()
if not user:
print(f"Error: No user found with email '{email}'")
return False
confirm = input(f"Delete {user.name} ({user.email}, role={user.role})? Type 'yes' to confirm: ")
if confirm.strip().lower() != "yes":
print("Cancelled.")
return False
db.delete(user)
db.commit()
print(f"Deleted user {user.name} ({user.email})")
return True
finally:
db.close()
def stats():
from app.models.quiz import Quiz
from app.models.question import Question
from app.models.attempt import QuizAttempt
from app.models.flashcard import FlashcardDeck, Flashcard
from app.models.pdf_document import PDFDocument
db = SessionLocal()
try:
users = db.query(User).count()
admins = db.query(User).filter(User.role == "admin").count()
mods = db.query(User).filter(User.role == "moderator").count()
quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None)).count()
course_quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.isnot(None)).count()
questions = db.query(Question).count()
attempts = db.query(QuizAttempt).filter(QuizAttempt.completed_at.isnot(None)).count()
in_progress = db.query(QuizAttempt).filter(QuizAttempt.completed_at.is_(None)).count()
decks = db.query(FlashcardDeck).count()
cards = db.query(Flashcard).count()
docs = db.query(PDFDocument).count()
try:
from app.models.course import Course, CourseEnrollment
courses = db.query(Course).count()
enrollments = db.query(CourseEnrollment).count()
except Exception:
courses = enrollments = "N/A"
print("Platform Statistics")
print("=" * 40)
print(f"Users: {users} ({admins} admin, {mods} moderator)")
print(f"Documents: {docs}")
print(f"Questions: {questions}")
print(f"Quizzes: {quizzes} (+{course_quizzes} course)")
print(f"Attempts: {attempts} completed, {in_progress} in-progress")
print(f"Flashcard decks: {decks} ({cards} cards)")
print(f"Courses: {courses}")
print(f"Enrollments: {enrollments}")
finally:
db.close()
def list_courses():
from app.models.course import Course, CourseEnrollment
db = SessionLocal()
try:
courses = db.query(Course).order_by(Course.id).all()
print(f"{'ID':<5} {'Title':<35} {'Status':<12} {'Creator':<20} {'Enrolled':<10}")
print("-" * 82)
for c in courses:
creator = db.query(User).filter(User.id == c.user_id).first()
name = creator.name if creator else "?"
print(f"{c.id:<5} {(c.title or '-')[:34]:<35} {c.status:<12} {name[:19]:<20} {c.enrolled_count or 0:<10}")
print(f"\n{len(courses)} course(s)")
finally:
db.close()
def list_quizzes(show_all=False):
from app.models.quiz import Quiz
db = SessionLocal()
try:
q = db.query(Quiz)
if not show_all:
q = q.filter(Quiz.deleted_at.is_(None))
quizzes = q.order_by(Quiz.id).all()
print(f"{'ID':<5} {'Title':<35} {'Qs':<5} {'Mode':<10} {'Type':<10} {'Status':<10}")
print("-" * 75)
for qz in quizzes:
qtype = "course" if qz.course_id else "standalone"
status = "deleted" if qz.deleted_at else ("shared" if qz.is_shared else "private")
print(f"{qz.id:<5} {(qz.title or '-')[:34]:<35} {qz.questions_count:<5} {qz.mode:<10} {qtype:<10} {status:<10}")
print(f"\n{len(quizzes)} quiz(zes)")
finally:
db.close()
def cleanup_redis():
from app.config import settings
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
keys = r.keys("quiz_progress:*")
expired = 0
for key in keys:
ttl = r.ttl(key)
if ttl == -1: # no expiry set — orphaned
r.delete(key)
expired += 1
session_keys = r.keys("quiz_session:*")
for key in session_keys:
ttl = r.ttl(key)
if ttl == -1:
r.delete(key)
expired += 1
print(f"Cleaned {expired} orphaned keys out of {len(keys) + len(session_keys)} total")
def export_users():
db = SessionLocal()
try:
users = db.query(User).order_by(User.id).all()
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["id", "name", "email", "role", "created_at"])
for u in users:
writer.writerow([u.id, u.name, u.email, u.role, u.created_at])
print(out.getvalue())
finally:
db.close()
COMMANDS = {
"list-users": (list_users, 0),
"reset-password": (lambda args: reset_password(args[0], args[1]), 2),
"set-role": (lambda args: set_role(args[0], args[1]), 2),
"verify-email": (lambda args: verify_email(args[0]), 1),
"delete-user": (lambda args: delete_user(args[0]), 1),
"stats": (stats, 0),
"list-courses": (list_courses, 0),
"list-quizzes": (lambda args: list_quizzes("--all" in args), 0),
"cleanup-redis": (cleanup_redis, 0),
"export-users": (export_users, 0),
}
if __name__ == "__main__":
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help", "help"):
print(__doc__)
sys.exit(0)
cmd = args[0]
rest = args[1:]
if cmd not in COMMANDS:
print(f"Unknown command: {cmd}")
print("Run with --help to see available commands")
sys.exit(1)
fn, min_args = COMMANDS[cmd]
if len(rest) < min_args:
print(f"Error: '{cmd}' requires {min_args} argument(s), got {len(rest)}")
sys.exit(1)
result = fn(rest) if min_args > 0 else fn()
if result is False:
sys.exit(1)

View file

@ -196,6 +196,35 @@ def setup_pgvector():
ADD COLUMN IF NOT EXISTS question_category_id INTEGER
REFERENCES question_categories(id) ON DELETE SET NULL
"""))
# Course quiz flag
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE
"""))
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS max_attempts INTEGER
"""))
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS questions_per_attempt INTEGER
"""))
conn.execute(text("""
ALTER TABLE quiz_attempts
ADD COLUMN IF NOT EXISTS selected_question_ids JSON
"""))
# Question ownership
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
"""))
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 1
"""))
# Fix email collation to avoid en_US.utf8 B-tree index corruption
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
@ -321,6 +350,7 @@ def setup_pgvector():
"""))
# Quiz sharing
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS allow_review INTEGER DEFAULT 1"))
# ── Course / LMS tables ──────────────────────────────────────
conn.execute(text("""
CREATE TABLE IF NOT EXISTS courses (

View file

@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from app.database import Base
@ -16,6 +16,7 @@ class QuizAttempt(Base):
total_questions = Column(Integer, default=0)
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses
quiz = relationship("Quiz", back_populates="attempts")
user = relationship("User", back_populates="attempts")

View file

@ -23,6 +23,8 @@ class Question(Base):
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
question_category = relationship("QuestionCategory", back_populates="questions",

View file

@ -24,6 +24,10 @@ class Quiz(Base):
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only)
is_shared = Column(Integer, default=0) # 0=private, 1=shared (visible to other users)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=True) # set = course-only quiz, hidden from main page
max_attempts = Column(Integer, nullable=True) # null = unlimited
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")

View file

@ -36,6 +36,16 @@ def start_attempt(
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
# Enforce max_attempts
if quiz.max_attempts:
completed_count = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).count()
if completed_count >= quiz.max_attempts:
raise HTTPException(status_code=403, detail="Maximum attempts reached for this quiz")
# Reuse the most recent incomplete attempt unless fresh=true
if not fresh:
existing = db.query(QuizAttempt).filter(
@ -54,10 +64,20 @@ def start_attempt(
completed_at=None,
)
# Question pool: randomly select N questions if questions_per_attempt is set
import random
selected_ids = None
total_q = quiz.questions_count
if quiz.questions_per_attempt and quiz.questions_per_attempt < quiz.questions_count:
all_q_ids = [q.id for q in quiz.questions]
selected_ids = sorted(random.sample(all_q_ids, quiz.questions_per_attempt))
total_q = quiz.questions_per_attempt
attempt = QuizAttempt(
quiz_id=quiz_id,
user_id=current_user.id,
total_questions=quiz.questions_count,
total_questions=total_q,
selected_question_ids=selected_ids,
)
db.add(attempt)
db.commit()
@ -110,21 +130,27 @@ def submit_attempt(
is_correct=is_correct,
))
# Check if review is allowed (course quizzes may disallow)
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
is_course_quiz = quiz and quiz.course_id is not None
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
# Build full review — include ALL questions, unanswered marked as incorrect
answer_details = []
for q in get_quiz_questions(db, attempt.quiz_id):
user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=user_answer,
correct_answer=q.correct_answer,
is_correct=is_correct,
explanation=q.explanation,
))
if review_allowed:
for q in get_quiz_questions(db, attempt.quiz_id):
user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=user_answer,
correct_answer=q.correct_answer,
is_correct=is_correct,
explanation=q.explanation,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
@ -157,6 +183,8 @@ def submit_attempt(
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
course_id=quiz.course_id if quiz else None,
allow_review=review_allowed,
)
@ -338,7 +366,7 @@ def delete_attempt(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete own attempt + answers + reminders for that quiz."""
"""Delete own attempt + answers + reminders for that quiz. Cannot delete course quiz attempts."""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
@ -346,6 +374,11 @@ def delete_attempt(
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
# Block deletion of course quiz attempts
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
if quiz and quiz.course_id:
raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts")
quiz_id = attempt.quiz_id
# Delete all attempts for this quiz by this user (wipe history)
from app.models.reminder import ReminderSchedule
@ -387,11 +420,18 @@ def get_in_progress_attempts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return all incomplete (not yet submitted) attempts for this user."""
incomplete = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).all()
"""Return all incomplete (not yet submitted) attempts for this user. Excludes course quizzes."""
incomplete = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
Quiz.course_id.is_(None), # exclude course quizzes
)
.order_by(QuizAttempt.started_at.desc())
.all()
)
result = []
for a in incomplete:
@ -411,11 +451,18 @@ def get_quiz_history(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return per-quiz attempt history for the performance line graph."""
completed = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).order_by(QuizAttempt.completed_at).all()
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes)."""
completed = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
)
.order_by(QuizAttempt.completed_at)
.all()
)
# Group by quiz
from collections import defaultdict
@ -446,16 +493,28 @@ def get_dashboard_stats(
current_user: User = Depends(get_current_user),
):
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
# Count distinct quizzes the user has attempted (not created)
total_quizzes = db.query(QuizAttempt.quiz_id).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).distinct().count()
# Count distinct quizzes the user has attempted (excludes course quizzes)
total_quizzes = (
db.query(QuizAttempt.quiz_id)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
)
.distinct().count()
)
completed_attempts = db.query(QuizAttempt).filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).all()
completed_attempts = (
db.query(QuizAttempt)
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
.filter(
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
Quiz.course_id.is_(None),
)
.all()
)
total_attempts = len(completed_attempts)
avg_score = 0.0
@ -502,21 +561,26 @@ def get_attempt(
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first()
is_course_quiz = quiz and quiz.course_id is not None
review_allowed = not is_course_quiz or (quiz.allow_review == 1)
# Show ALL questions in review, not just answered ones
submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = []
for q in get_quiz_questions(db, attempt.quiz_id):
ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=ans.user_answer if ans else "",
correct_answer=q.correct_answer,
is_correct=ans.is_correct if ans else False,
explanation=q.explanation,
))
if review_allowed:
for q in get_quiz_questions(db, attempt.quiz_id):
ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail(
question_id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=q.options,
user_answer=ans.user_answer if ans else "",
correct_answer=q.correct_answer,
is_correct=ans.is_correct if ans else False,
explanation=q.explanation,
))
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
return AttemptDetail(
@ -528,4 +592,6 @@ def get_attempt(
started_at=attempt.started_at,
completed_at=attempt.completed_at,
answers=answer_details,
course_id=quiz.course_id if quiz else None,
allow_review=review_allowed,
)

View file

@ -1,6 +1,8 @@
import hashlib
import secrets
from datetime import datetime, timedelta
import httpx
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
@ -19,6 +21,26 @@ from app.utils.auth import (
router = APIRouter()
def _check_pwned_password(password: str) -> int | None:
"""Check if password appears in Have I Been Pwned breach database.
Uses k-anonymity: only first 5 chars of SHA-1 hash are sent.
Returns breach count, 0 if clean, or None if check failed."""
try:
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3)
if resp.status_code != 200:
return None
for line in resp.text.splitlines():
hash_suffix, count = line.split(":")
if hash_suffix == suffix:
return int(count)
return 0
except Exception:
return None # fail open — don't block registration if API is down
def _check_login_rate_limit(client_ip: str):
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
try:
@ -107,6 +129,15 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# Check breached passwords (warn, don't block)
pwned_count = _check_pwned_password(user_data.password)
password_warning = None
if pwned_count and pwned_count > 0:
password_warning = (
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
"Consider changing it to something unique."
)
email_normalized = user_data.email.lower().strip()
existing = db.query(User).filter(User.email == email_normalized).first()
if existing:
@ -135,11 +166,17 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
if is_first_user:
# Auto-verified admin — log them in immediately
access_token = create_access_token(data={"sub": user.email})
return {"access_token": access_token, "token_type": "bearer"}
resp = {"access_token": access_token, "token_type": "bearer"}
if password_warning:
resp["password_warning"] = password_warning
return resp
# Regular user — must verify email before logging in
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
resp = {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
if password_warning:
resp["password_warning"] = password_warning
return resp
@router.post("/login", response_model=Token)
@ -252,10 +289,17 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
if not user:
raise HTTPException(status_code=404, detail="User not found")
pwned_count = _check_pwned_password(data.new_password)
user.hashed_password = get_password_hash(data.new_password)
record.used = True
db.commit()
return {"message": "Password reset successfully. You can now log in."}
resp = {"message": "Password reset successfully. You can now log in."}
if pwned_count and pwned_count > 0:
resp["password_warning"] = (
f"Warning: this password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
"Consider changing it to something more unique."
)
return resp
@router.get("/me/settings")
@ -293,8 +337,9 @@ def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=UserResponse)
@router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
password_warning = None
if data.new_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
@ -302,6 +347,12 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
pwned_count = _check_pwned_password(data.new_password)
if pwned_count and pwned_count > 0:
password_warning = (
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
"Consider changing it to something more unique."
)
current_user.hashed_password = get_password_hash(data.new_password)
if data.name:
@ -309,4 +360,10 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
db.commit()
db.refresh(current_user)
return current_user
resp = {
"id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role,
}
if password_warning:
resp["password_warning"] = password_warning
return resp

View file

@ -7,7 +7,7 @@ from datetime import datetime
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
@ -16,6 +16,8 @@ from app.database import get_db
from app.models.course import (
Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress,
)
from app.models.question import Question
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user
@ -26,27 +28,25 @@ router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────
class CourseCreate(BaseModel):
title: str
description: str | None = None
title: str = Field(..., min_length=1, max_length=300)
description: str | None = Field(None, max_length=5000)
class CourseUpdate(BaseModel):
title: str | None = None
description: str | None = None
category: str | None = None
thumbnail_url: str | None = None
title: str | None = Field(None, max_length=300)
description: str | None = Field(None, max_length=5000)
requires_subscription: int | None = None
class ModuleCreate(BaseModel):
title: str
description: str | None = None
title: str = Field(..., min_length=1, max_length=300)
description: str | None = Field(None, max_length=2000)
position: int | None = None
class ModuleUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: str | None = Field(None, max_length=300)
description: str | None = Field(None, max_length=2000)
position: int | None = None
@ -55,17 +55,21 @@ class ModuleReorder(BaseModel):
class LessonCreate(BaseModel):
title: str
title: str = Field(..., min_length=1, max_length=300)
lesson_type: str = "video" # video, document, quiz, live
content_text: str | None = None
video_url: str | None = None
quiz_id: int | None = None
live_session_url: str | None = None
live_session_start: str | None = None
live_session_end: str | None = None
duration_minutes: int | None = None
is_required: int = 1
position: int | None = None
class LessonUpdate(BaseModel):
title: str | None = None
title: str | None = Field(None, max_length=300)
lesson_type: str | None = None
content_text: str | None = None
video_url: str | None = None
@ -73,6 +77,10 @@ class LessonUpdate(BaseModel):
duration_minutes: int | None = None
is_required: int | None = None
position: int | None = None
quiz_id: int | None = None
live_session_url: str | None = None
live_session_start: str | None = None
live_session_end: str | None = None
class ProgressUpdate(BaseModel):
@ -85,6 +93,7 @@ class AIGenerateRequest(BaseModel):
prompt: str
action: str = "generate" # generate, refine, summarize
existing_content: str | None = None
model_id: str | None = None # optional override model
# ── Helpers ──────────────────────────────────────────────────────────
@ -121,7 +130,9 @@ def create_course(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new course (any authenticated user). Starts as draft."""
"""Create a new course (moderators and admins only). Starts as draft."""
if not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Only moderators and admins can create courses")
course = Course(
title=data.title,
description=data.description,
@ -164,6 +175,13 @@ def list_courses(
.all()
)
# Pre-fetch module counts
mod_counts = dict(
db.query(CourseModule.course_id, func.count(CourseModule.id))
.group_by(CourseModule.course_id)
.all()
)
user_cache: dict[int, str] = {}
result = []
for course, enrolled in rows:
@ -180,13 +198,13 @@ def list_courses(
"id": course.id,
"title": course.title,
"description": course.description,
"status": course.status,
"thumbnail_path": course.thumbnail_path,
"requires_subscription": course.requires_subscription,
"user_id": course.user_id,
"creator_name": user_cache[course.user_id],
"enrolled_count": int(enrolled),
"module_count": mod_counts.get(course.id, 0),
"created_at": course.created_at,
"updated_at": course.updated_at,
})
@ -226,6 +244,23 @@ def list_published_courses(
.all()
)
# Pre-fetch module counts
course_ids = [c.id for c, _ in rows]
mod_counts = dict(
db.query(CourseModule.course_id, func.count(CourseModule.id))
.filter(CourseModule.course_id.in_(course_ids))
.group_by(CourseModule.course_id)
.all()
) if course_ids else {}
# Check which courses current user is enrolled in
my_enrolled_ids = set(
db.query(CourseEnrollment.course_id)
.filter(CourseEnrollment.user_id == current_user.id, CourseEnrollment.course_id.in_(course_ids))
.all()
)
my_enrolled_ids = {row[0] for row in my_enrolled_ids}
user_cache: dict[int, str] = {}
courses = []
for course, enrolled in rows:
@ -236,12 +271,13 @@ def list_published_courses(
"id": course.id,
"title": course.title,
"description": course.description,
"thumbnail_path": course.thumbnail_path,
"requires_subscription": course.requires_subscription,
"user_id": course.user_id,
"creator_name": user_cache[course.user_id],
"enrolled_count": int(enrolled),
"module_count": mod_counts.get(course.id, 0),
"is_enrolled": course.id in my_enrolled_ids,
"created_at": course.created_at,
})
@ -260,18 +296,28 @@ def my_courses(
.all()
)
course_ids = [e.course_id for e in enrollments]
mod_counts = dict(
db.query(CourseModule.course_id, func.count(CourseModule.id))
.filter(CourseModule.course_id.in_(course_ids))
.group_by(CourseModule.course_id)
.all()
) if course_ids else {}
result = []
for enrollment in enrollments:
course = db.query(Course).filter(Course.id == enrollment.course_id).first()
if not course:
continue
owner = db.query(User).filter(User.id == course.user_id).first()
result.append({
"id": course.id,
"title": course.title,
"description": course.description,
"thumbnail_path": course.thumbnail_path,
"status": course.status,
"creator_name": owner.name if owner else "Unknown",
"module_count": mod_counts.get(course.id, 0),
"enrollment_id": enrollment.id,
"progress_pct": enrollment.progress_pct,
"enrolled_at": enrollment.enrolled_at,
@ -281,6 +327,57 @@ def my_courses(
return result
def _build_lesson_dict(les, db, user_id):
"""Build lesson dict with quiz metadata if applicable."""
d = {
"id": les.id,
"title": les.title,
"lesson_type": les.lesson_type,
"content_text": les.content_text,
"video_url": les.video_url,
"video_provider": les.video_provider,
"local_file_path": les.local_file_path,
"duration_minutes": les.duration_minutes,
"quiz_id": les.quiz_id,
"live_session_url": les.live_session_url,
"live_session_start": les.live_session_start,
"live_session_end": les.live_session_end,
"description": les.description,
"is_required": les.is_required,
"position": les.position,
"bbb_meeting_id": les.bbb_meeting_id,
}
# Add quiz info for quiz lessons
if les.quiz_id and les.lesson_type == "quiz":
from app.models.attempt import QuizAttempt
quiz = db.query(Quiz).filter(Quiz.id == les.quiz_id).first()
if quiz:
attempts = (
db.query(QuizAttempt)
.filter(QuizAttempt.quiz_id == quiz.id, QuizAttempt.user_id == user_id)
.order_by(QuizAttempt.completed_at.desc())
.all()
)
d["quiz_title"] = quiz.title
d["quiz_allow_review"] = quiz.allow_review
d["quiz_mode"] = quiz.mode
d["quiz_time_limit"] = quiz.time_limit_minutes
d["quiz_max_attempts"] = quiz.max_attempts
d["quiz_questions_count"] = quiz.questions_count
d["quiz_questions_per_attempt"] = quiz.questions_per_attempt
d["quiz_attempts"] = [
{
"id": a.id,
"score": a.score,
"total": a.total_questions,
"percentage": round(a.score / a.total_questions * 100) if a.total_questions else 0,
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
}
for a in attempts
]
return d
@router.get("/{course_id}")
def get_course(
course_id: int,
@ -329,24 +426,7 @@ def get_course(
"description": mod.description,
"position": mod.position,
"lessons": [
{
"id": les.id,
"title": les.title,
"lesson_type": les.lesson_type,
"content_text": les.content_text,
"video_url": les.video_url,
"video_provider": les.video_provider,
"local_file_path": les.local_file_path,
"duration_minutes": les.duration_minutes,
"quiz_id": les.quiz_id,
"live_session_url": les.live_session_url,
"live_session_start": les.live_session_start,
"live_session_end": les.live_session_end,
"description": les.description,
"is_required": les.is_required,
"position": les.position,
"bbb_meeting_id": les.bbb_meeting_id,
}
_build_lesson_dict(les, db, current_user.id)
for les in lessons
],
})
@ -375,7 +455,7 @@ def get_course(
"lesson_progress": {
lp.lesson_id: {
"status": lp.status,
"time_spent": lp.time_spent,
"time_spent": lp.time_spent_seconds,
"score": lp.score,
"completed_at": lp.completed_at,
}
@ -449,6 +529,20 @@ def publish_course(
return {"id": course.id, "status": course.status}
@router.post("/{course_id}/unpublish")
def unpublish_course(
course_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Set course status back to draft (creator or admin)."""
course = _course_owner_or_admin(course_id, current_user, db)
course.status = "draft"
course.updated_at = datetime.utcnow()
db.commit()
return {"id": course.id, "status": course.status}
@router.post("/{course_id}/thumbnail")
async def upload_thumbnail(
course_id: int,
@ -591,6 +685,112 @@ def reorder_modules(
return {"reordered": True, "count": len(data.module_ids)}
class LessonReorder(BaseModel):
lesson_ids: list[int]
@router.put("/{course_id}/modules/{module_id}/lessons/reorder")
def reorder_lessons(
course_id: int,
module_id: int,
data: LessonReorder,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Reorder lessons within a module by providing list of lesson IDs in desired order."""
_course_owner_or_admin(course_id, current_user, db)
for idx, lesson_id in enumerate(data.lesson_ids):
lesson = (
db.query(CourseLesson)
.filter(CourseLesson.id == lesson_id, CourseLesson.module_id == module_id)
.first()
)
if lesson:
lesson.position = idx + 1
db.commit()
return {"reordered": True, "count": len(data.lesson_ids)}
# ── Course Quiz Creation ─────────────────────────────────────────────
class CourseQuizCreate(BaseModel):
question_ids: list[int]
title: str | None = None
mode: str = "learning" # timed or learning
time_limit_minutes: int | None = None
max_attempts: int | None = None # null = unlimited
questions_per_attempt: int | None = None # null = all questions; set to pick random subset
allow_review: bool = True # whether students can review answers after submit
@router.post("/{course_id}/quiz")
def create_course_quiz(
course_id: int,
data: CourseQuizCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a course-only quiz by copying selected questions. The quiz and copies
are independent from the main question bank and quiz list."""
_course_owner_or_admin(course_id, current_user, db)
if not data.question_ids:
raise HTTPException(status_code=400, detail="Select at least one question")
source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
if not source_questions:
raise HTTPException(status_code=404, detail="No valid questions found")
# Preserve caller order
id_order = {qid: i for i, qid in enumerate(data.question_ids)}
source_questions.sort(key=lambda q: id_order.get(q.id, 999))
# Create quiz tied to this course
qpa = data.questions_per_attempt
if qpa and qpa > len(source_questions):
qpa = None # can't ask for more than the pool
quiz = Quiz(
user_id=current_user.id,
title=data.title or f"Course Quiz ({len(source_questions)} questions)",
questions_count=len(source_questions),
mode=data.mode if data.mode in ("timed", "learning") else "learning",
time_limit_minutes=data.time_limit_minutes,
max_attempts=data.max_attempts,
questions_per_attempt=qpa,
allow_review=1 if data.allow_review else 0,
is_published=0,
is_shared=0,
course_id=course_id,
)
db.add(quiz)
db.flush()
# Copy each question (independent copies)
from app.models.quiz_question_link import QuizQuestionLink
for pos, sq in enumerate(source_questions):
copy = Question(
question_text=sq.question_text,
question_type=sq.question_type,
options=sq.options,
correct_answer=sq.correct_answer,
explanation=sq.explanation,
image_path=sq.image_path,
question_category_id=sq.question_category_id,
source_quiz_id=quiz.id,
user_id=current_user.id,
is_shared=0,
)
db.add(copy)
db.flush()
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=copy.id, position=pos))
quiz.questions_count = len(source_questions)
db.commit()
db.refresh(quiz)
return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count}
# ── Lesson CRUD ──────────────────────────────────────────────────────
@router.post("/{course_id}/modules/{module_id}/lessons")
@ -631,6 +831,10 @@ def create_lesson(
content_text=data.content_text,
video_url=data.video_url,
video_provider=video_provider,
quiz_id=data.quiz_id,
live_session_url=data.live_session_url,
live_session_start=data.live_session_start,
live_session_end=data.live_session_end,
duration_minutes=data.duration_minutes,
is_required=data.is_required,
position=position,
@ -751,6 +955,165 @@ async def upload_lesson_file(
# ── Enrollment ───────────────────────────────────────────────────────
@router.get("/{course_id}/enrollees")
def list_enrollees(
course_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all enrollees with progress and quiz scores. Creator or admin only."""
course = _course_owner_or_admin(course_id, current_user, db)
enrollments = (
db.query(CourseEnrollment)
.filter(CourseEnrollment.course_id == course_id)
.all()
)
# Get all quiz lessons for this course
from app.models.attempt import QuizAttempt
quiz_lessons = (
db.query(CourseLesson)
.join(CourseModule, CourseLesson.module_id == CourseModule.id)
.filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None))
.all()
)
quiz_ids = [l.quiz_id for l in quiz_lessons]
quiz_titles = {}
for l in quiz_lessons:
q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first()
quiz_titles[l.quiz_id] = q.title if q else l.title
result = []
for enrollment in enrollments:
user = db.query(User).filter(User.id == enrollment.user_id).first()
if not user:
continue
# Lesson progress
lesson_progress = (
db.query(CourseLessonProgress)
.filter(CourseLessonProgress.enrollment_id == enrollment.id)
.all()
)
completed_count = sum(1 for lp in lesson_progress if lp.status == "completed")
# Quiz scores
quiz_scores = []
for qid in quiz_ids:
best = (
db.query(QuizAttempt)
.filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None))
.order_by(QuizAttempt.score.desc())
.first()
)
if best:
quiz_scores.append({
"quiz_id": qid,
"quiz_title": quiz_titles.get(qid, f"Quiz {qid}"),
"best_score": best.score,
"total": best.total_questions,
"percentage": round(best.score / best.total_questions * 100) if best.total_questions else 0,
"attempts": db.query(func.count(QuizAttempt.id)).filter(
QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)
).scalar(),
})
result.append({
"user_id": enrollment.user_id,
"name": user.name,
"email": user.email,
"progress_pct": enrollment.progress_pct or 0,
"completed_lessons": completed_count,
"enrolled_at": enrollment.enrolled_at,
"completed_at": enrollment.completed_at,
"quiz_scores": quiz_scores,
})
return result
@router.get("/{course_id}/enrollees/export")
def export_enrollees_csv(
course_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Export enrollee data as CSV. Creator or admin only."""
import csv as csv_mod
import io as io_mod
from fastapi.responses import StreamingResponse
from app.models.attempt import QuizAttempt
course = _course_owner_or_admin(course_id, current_user, db)
enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == course_id).all()
quiz_lessons = (
db.query(CourseLesson)
.join(CourseModule, CourseLesson.module_id == CourseModule.id)
.filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None))
.all()
)
quiz_ids = [l.quiz_id for l in quiz_lessons]
quiz_titles = {}
for l in quiz_lessons:
q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first()
quiz_titles[l.quiz_id] = q.title if q else l.title
buf = io_mod.StringIO()
writer = csv_mod.writer(buf)
# Header
header = ["Name", "Email", "Progress %", "Completed Lessons", "Enrolled Date", "Completed Date"]
for qid in quiz_ids:
header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Best %)")
header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Attempts)")
writer.writerow(header)
for enrollment in enrollments:
user = db.query(User).filter(User.id == enrollment.user_id).first()
if not user:
continue
lp_count = db.query(func.count(CourseLessonProgress.id)).filter(
CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.status == "completed"
).scalar()
row = [
user.name, user.email,
enrollment.progress_pct or 0,
lp_count,
enrollment.enrolled_at.strftime("%Y-%m-%d") if enrollment.enrolled_at else "",
enrollment.completed_at.strftime("%Y-%m-%d") if enrollment.completed_at else "",
]
for qid in quiz_ids:
best = (
db.query(QuizAttempt)
.filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None))
.order_by(QuizAttempt.score.desc())
.first()
)
if best:
row.append(round(best.score / best.total_questions * 100) if best.total_questions else 0)
row.append(db.query(func.count(QuizAttempt.id)).filter(
QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)
).scalar())
else:
row.append("")
row.append(0)
writer.writerow(row)
buf.seek(0)
# Add UTF-8 BOM for LibreOffice/Excel compatibility
csv_content = "\ufeff" + buf.getvalue()
filename = f"{course.title.replace(' ', '_')}_enrollees.csv"
return StreamingResponse(
iter([csv_content]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.post("/{course_id}/enroll")
def enroll(
course_id: int,
@ -868,7 +1231,7 @@ def update_progress(
if data.status == "completed" and not progress.completed_at:
progress.completed_at = datetime.utcnow()
if data.time_spent is not None:
progress.time_spent = (progress.time_spent or 0) + data.time_spent
progress.time_spent_seconds = (progress.time_spent_seconds or 0) + data.time_spent
if data.score is not None:
progress.score = data.score
@ -904,7 +1267,7 @@ def update_progress(
return {
"lesson_id": lesson_id,
"status": progress.status,
"time_spent": progress.time_spent,
"time_spent": progress.time_spent_seconds,
"score": progress.score,
"enrollment_progress_pct": enrollment.progress_pct,
}
@ -925,11 +1288,23 @@ def ai_generate_standalone(
from app.services.ai_service import get_model_for_task, _proxy_model
import litellm
model_id, api_key = get_model_for_task(db, "teach")
if data.model_id:
model_id = data.model_id
# Look up API key for this specific model
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first()
api_key = config.api_key if config else None
else:
model_id, api_key = get_model_for_task(db, "teach")
system_msg = (
"You are a medical education content specialist for pediatrics. "
"Generate high-quality lesson content in markdown format. "
"Include relevant medical information, key concepts, and learning objectives."
"Generate high-quality lesson content in clean standard markdown. "
"Use ## for section headings (never # — the title is separate). "
"Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, "
"and standard markdown tables (| col | col |). "
"Include learning objectives, key concepts, tables, and clinical pearls. "
"If the user provides existing content, you can see it and modify/extend it as instructed."
)
user_msg = data.prompt
if data.existing_content:
@ -942,6 +1317,8 @@ def ai_generate_standalone(
], "max_tokens": 4000}
if api_key:
kwargs["api_key"] = api_key
elif settings.LITELLM_API_KEY:
kwargs["api_key"] = settings.LITELLM_API_KEY
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
response = litellm.completion(**kwargs)
@ -974,7 +1351,13 @@ def ai_generate_content(
from app.services.ai_service import get_model_for_task, _proxy_model
import litellm
model_id, api_key = get_model_for_task(db, "teach")
if data.model_id:
model_id = data.model_id
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first()
api_key = config.api_key if config else None
else:
model_id, api_key = get_model_for_task(db, "teach")
if data.action == "refine" and data.existing_content:
system_msg = (
@ -1000,8 +1383,12 @@ def ai_generate_content(
else:
system_msg = (
"You are a medical education content specialist for pediatrics. "
"Generate high-quality lesson content in markdown format. "
"Include relevant medical information, key concepts, and learning objectives."
"Generate high-quality lesson content in clean standard markdown. "
"Use ## for section headings (never # — the title is separate). "
"Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, "
"and standard markdown tables (| col | col |). "
"Include learning objectives, key concepts, tables, and clinical pearls. "
"If the user provides existing content, you can see it and modify/extend it as instructed."
)
user_msg = (
f"Lesson title: {lesson.title}\n\n"
@ -1019,6 +1406,8 @@ def ai_generate_content(
}
if api_key:
kwargs["api_key"] = api_key
elif settings.LITELLM_API_KEY:
kwargs["api_key"] = settings.LITELLM_API_KEY
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
@ -1105,14 +1494,24 @@ def bbb_join_meeting(
if not lesson.bbb_meeting_id:
raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson")
# Verify user is enrolled or is the course owner/admin
course = db.query(Course).filter(Course.id == course_id).first()
is_owner = course and (course.user_id == current_user.id or current_user.is_admin)
if not is_owner:
enrolled = db.query(CourseEnrollment).filter(
CourseEnrollment.course_id == course_id,
CourseEnrollment.user_id == current_user.id,
).first()
if not enrolled:
raise HTTPException(status_code=403, detail="You are not enrolled in this course")
bbb_url = getattr(settings, "BBB_SERVER_URL", None)
bbb_secret = getattr(settings, "BBB_SECRET", None)
if not bbb_url or not bbb_secret:
raise HTTPException(status_code=501, detail="BBB not configured")
# Course owner/admin joins as moderator, others as attendee
course = db.query(Course).filter(Course.id == course_id).first()
is_moderator = course and (course.user_id == current_user.id or current_user.is_admin)
is_moderator = is_owner
password = "mp" if is_moderator else "ap"
params = urlencode({

View file

@ -1,7 +1,13 @@
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
import csv
import io
import os
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import cast, String, or_, text as sa_text
from sqlalchemy import cast, String, or_, text as sa_text, func
from sqlalchemy.orm import Session
from app.database import get_db
@ -15,6 +21,84 @@ from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.delete("/{question_id}", status_code=204)
def delete_question(
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a question. Moderators can delete any; regular users can delete their own."""
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
is_mod = current_user.role in ("admin", "moderator")
if not is_mod:
# Regular users can only delete questions they created (no source quiz)
if question.source_quiz_id is not None:
raise HTTPException(status_code=403, detail="Only moderators can delete extracted questions")
db.delete(question)
db.commit()
class QuestionEdit(BaseModel):
question_text: str | None = None
question_type: str | None = None
options: list[str] | None = None
correct_answer: str | None = None
explanation: str | None = None
question_category_id: int | None = None
image_path: str | None = None
@router.patch("/{question_id}")
def edit_question(
question_id: int,
data: QuestionEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Edit a question. Owner or moderator can edit."""
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
is_mod = current_user.role in ("admin", "moderator")
if question.user_id != current_user.id and not is_mod:
raise HTTPException(status_code=403, detail="Not authorized to edit this question")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(question, field, value)
db.commit()
db.refresh(question)
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
"question_category_id": question.question_category_id,
"image_path": question.image_path,
}
@router.patch("/{question_id}/share")
def toggle_question_share(
question_id: int,
shared: int = Query(..., description="1 = shared, 0 = private"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle question sharing. Owner or admin can change."""
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
is_admin = current_user.role in ("admin", "moderator")
if question.user_id != current_user.id and not is_admin:
raise HTTPException(status_code=403, detail="Not your question")
question.is_shared = 1 if shared else 0
db.commit()
return {"id": question_id, "is_shared": question.is_shared}
@router.patch("/{question_id}/category")
def set_question_category(
question_id: int,
@ -98,6 +182,7 @@ def get_question_bank(
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
my_questions: bool = Query(False, description="Show only questions created by current user"),
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),
@ -106,7 +191,16 @@ def get_question_bank(
current_user: User = Depends(get_current_user),
):
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
query = db.query(Question)
if my_questions:
query = db.query(Question).filter(Question.user_id == current_user.id)
else:
query = db.query(Question).filter(
or_(
Question.is_shared == 1,
Question.is_shared.is_(None), # legacy questions without is_shared
Question.user_id == current_user.id, # always show own questions
)
)
if quiz_id:
query = query.filter(Question.source_quiz_id == quiz_id)
@ -216,11 +310,30 @@ def get_question_bank(
"correct_answer": qu.correct_answer,
"explanation": qu.explanation,
"image_path": qu.image_path,
"user_id": qu.user_id,
"is_shared": qu.is_shared if qu.is_shared is not None else 1,
})
return {"total": total, "questions": result}
def _strip_html(text: str) -> str:
"""Remove HTML tags and decode entities, returning plain text."""
if not text:
return text
# Remove tags
clean = re.sub(r"<[^>]+>", " ", text)
# Decode common entities
clean = clean.replace("&nbsp;", " ").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"')
# Collapse whitespace
clean = re.sub(r"\s+", " ", clean).strip()
return clean
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "uploads", "questions")
os.makedirs(UPLOAD_DIR, exist_ok=True)
class ManualQuestionCreate(BaseModel):
question_text: str
question_type: str = "mcq"
@ -228,6 +341,7 @@ class ManualQuestionCreate(BaseModel):
correct_answer: str
explanation: str | None = None
question_category_id: int | None = None
image_path: str | None = None
@router.post("/create")
@ -237,7 +351,8 @@ def create_question_manually(
current_user: User = Depends(get_current_user),
):
"""Create a single question manually (not from PDF extraction)."""
if not data.question_text.strip():
q_text = data.question_text.strip()
if not q_text:
raise HTTPException(status_code=400, detail="Question text is required")
if not data.correct_answer.strip():
raise HTTPException(status_code=400, detail="Correct answer is required")
@ -247,12 +362,15 @@ def create_question_manually(
raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
question = Question(
question_text=data.question_text.strip(),
question_text=q_text,
question_type=data.question_type,
options=data.options,
correct_answer=data.correct_answer.strip(),
explanation=data.explanation,
question_category_id=data.question_category_id,
image_path=data.image_path,
user_id=current_user.id,
is_shared=1,
)
db.add(question)
db.commit()
@ -272,9 +390,46 @@ def create_question_manually(
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"image_path": question.image_path,
}
@router.post("/upload-image")
def upload_question_image(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload an image for use in questions. Returns the relative path."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "png"
if ext not in ("png", "jpg", "jpeg", "gif", "webp", "svg"):
raise HTTPException(status_code=400, detail="Unsupported image format")
filename = f"{uuid.uuid4().hex[:12]}.{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(file.file.read())
rel_path = f"questions/{filename}"
return {"image_path": rel_path, "url": f"/uploads/{rel_path}"}
@router.get("/images")
def list_question_images(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all unique question images for the image bank browser."""
images = (
db.query(Question.image_path)
.filter(Question.image_path.isnot(None), Question.image_path != "")
.distinct()
.limit(200)
.all()
)
return [{"image_path": img[0], "url": f"/uploads/{img[0]}"} for img in images]
class CreateFromBankRequest(BaseModel):
title: str
question_ids: list[int]
@ -354,3 +509,150 @@ def bulk_set_question_category(
)
db.commit()
return {"updated": updated, "question_category_id": data.category_id}
@router.get("/import/sample")
def download_sample_csv():
"""Download a sample CSV template for question import."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["question_text", "option_a", "option_b", "option_c", "option_d", "option_e", "correct_answer", "explanation", "category"])
writer.writerow([
"What is the most common cause of neonatal jaundice?",
"Physiological jaundice", "ABO incompatibility", "G6PD deficiency", "Breast milk jaundice", "",
"A",
"Physiological jaundice occurs in up to 60% of term neonates due to immature hepatic conjugation.",
"Neonatology",
])
writer.writerow([
"Which vaccine is given at birth?",
"BCG", "Hepatitis B", "Both BCG and Hepatitis B", "OPV", "",
"C",
"Both BCG and Hepatitis B are given at birth per the WHO immunization schedule.",
"Immunology",
])
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=question_import_sample.csv"},
)
@router.post("/import/upload")
def upload_questions_csv(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Import questions from CSV or XLSX file.
Expected columns: question_text, option_a, option_b, option_c, option_d, option_e,
correct_answer (A/B/C/D/E or full text), explanation, category
"""
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
raw = file.file.read()
rows = []
if ext in ("xlsx", "xls"):
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True)
ws = wb.active
header = None
for row in ws.iter_rows(values_only=True):
if header is None:
header = [str(c or "").strip().lower().replace(" ", "_") for c in row]
continue
rows.append(dict(zip(header, [str(c) if c is not None else "" for c in row])))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {e}")
elif ext == "csv":
try:
text = raw.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
for row in reader:
rows.append({k.strip().lower().replace(" ", "_"): (v or "").strip() for k, v in row.items()})
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {e}")
else:
raise HTTPException(status_code=400, detail="Unsupported file type. Use .csv or .xlsx")
if not rows:
raise HTTPException(status_code=400, detail="File is empty or has no data rows")
# Cache category lookups
cat_cache = {}
for cat in db.query(QuestionCategory).all():
cat_cache[cat.name.lower()] = cat.id
LETTER_MAP = {"a": 0, "b": 1, "c": 2, "d": 3, "e": 4}
created = []
errors = []
for i, row in enumerate(rows, start=2): # row 2 = first data row
q_text = row.get("question_text", "").strip()
if not q_text:
errors.append(f"Row {i}: missing question_text")
continue
options = []
for key in ("option_a", "option_b", "option_c", "option_d", "option_e"):
val = row.get(key, "").strip()
if val:
options.append(val)
correct_raw = row.get("correct_answer", "").strip()
correct_answer = ""
if correct_raw.lower() in LETTER_MAP and LETTER_MAP[correct_raw.lower()] < len(options):
correct_answer = options[LETTER_MAP[correct_raw.lower()]]
elif correct_raw in options:
correct_answer = correct_raw
else:
errors.append(f"Row {i}: invalid correct_answer '{correct_raw}'")
continue
explanation = row.get("explanation", "").strip() or None
cat_name = row.get("category", "").strip()
cat_id = None
if cat_name:
cat_lower = cat_name.lower()
if cat_lower not in cat_cache:
new_cat = QuestionCategory(name=cat_name, user_id=current_user.id)
db.add(new_cat)
db.flush()
cat_cache[cat_lower] = new_cat.id
cat_id = cat_cache[cat_lower]
question = Question(
question_text=q_text,
question_type="mcq" if len(options) >= 2 else "short_answer",
options=options if len(options) >= 2 else None,
correct_answer=correct_answer,
explanation=explanation,
question_category_id=cat_id,
)
db.add(question)
created.append(question)
db.commit()
# Embed in background
for q in created:
db.refresh(q)
try:
from app.services import embedding_service
embedding_service.embed_question(q)
except Exception:
pass
if created:
db.commit()
return {
"imported": len(created),
"errors": errors[:20], # cap error list
"total_rows": len(rows),
}

View file

@ -162,7 +162,7 @@ def search_quizzes(
def _ensure_quiz(quiz_id: int, match_type: str):
if quiz_id not in results:
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
if not quiz or quiz.course_id is not None:
return False
results[quiz_id] = {
"quiz_id": quiz.id,
@ -179,7 +179,7 @@ def search_quizzes(
# ── Title search ─────────────────────────────────────────────
if mode in ("title", "all"):
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all():
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%"), Quiz.course_id.is_(None)).limit(30).all():
_ensure_quiz(quiz.id, "title")
# ── Semantic (vector) search ──────────────────────────────────
@ -268,7 +268,7 @@ def list_quizzes(
current_user: User = Depends(get_current_user),
):
"""List quizzes. Moderators see all; regular users only see published."""
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None))
if not current_user.is_moderator:
q = q.filter(Quiz.is_published == 1)
return q.order_by(Quiz.created_at.desc()).all()
@ -278,17 +278,34 @@ def list_quizzes(
def get_quiz(
quiz_id: int,
study: bool = Query(False),
attempt_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz for taking. study=true forces answers/explanations to be included."""
"""Get quiz for taking. study=true forces answers/explanations to be included.
attempt_id filters to the question subset selected for that attempt (question pool)."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if study or quiz.mode == "learning":
return QuizLearningDetail.model_validate(quiz)
return QuizDetail.model_validate(quiz)
result = QuizLearningDetail.model_validate(quiz)
else:
result = QuizDetail.model_validate(quiz)
# Filter questions if attempt has a question pool selection
if attempt_id:
from app.models.attempt import QuizAttempt
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
if attempt and attempt.selected_question_ids:
sel = set(attempt.selected_question_ids)
result.questions = [q for q in result.questions if q.id in sel]
result.questions_count = len(result.questions)
return result
@router.patch("/{quiz_id}", response_model=QuizResponse)
@ -296,12 +313,14 @@ def update_quiz(
quiz_id: int,
data: QuizUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Update quiz metadata (title, etc.) — moderator only."""
"""Update quiz metadata (title, etc.) — owner or admin only."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if quiz.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Not authorized to edit this quiz")
if data.title:
quiz.title = data.title.strip()

View file

@ -34,6 +34,8 @@ class AttemptResponse(BaseModel):
percentage: float
started_at: datetime
completed_at: datetime | None
course_id: int | None = None
allow_review: bool = True
class Config:
from_attributes = True

View file

@ -53,6 +53,7 @@ class QuizResponse(BaseModel):
deleted_at: datetime | None = None
is_published: int = 1
is_shared: int = 0
questions_per_attempt: int | None = None
class Config:
from_attributes = True

View file

@ -1,9 +1,9 @@
from datetime import datetime, timedelta
import bcrypt
from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from starlette.middleware.base import BaseHTTPMiddleware
@ -11,16 +11,15 @@ from app.config import settings
from app.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:

View file

@ -4,8 +4,7 @@ uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
alembic==1.13.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
bcrypt>=4.0.1
python-multipart==0.0.9
pydantic[email]==2.6.1
pydantic-settings==2.1.0
@ -21,3 +20,4 @@ python-dotenv==1.0.1
httpx==0.27.0
boto3==1.34.69
pgvector==0.3.6
openpyxl==3.1.2

25
backup-db.sh Executable file
View file

@ -0,0 +1,25 @@
#!/bin/bash
# Periodic PostgreSQL backup for PedsHub
# Keeps the last 14 daily backups, rotating old ones out.
BACKUP_DIR="/home/danvics/docker/quiz/backups"
COMPOSE_DIR="/home/danvics/docker/quiz"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILENAME="pedquiz_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
# Dump via the running postgres container
docker compose -f "$COMPOSE_DIR/docker-compose.yml" exec -T postgres \
pg_dump -U pedquiz pedquiz | gzip > "$BACKUP_DIR/$FILENAME"
if [ $? -eq 0 ] && [ -s "$BACKUP_DIR/$FILENAME" ]; then
echo "[$(date)] Backup OK: $FILENAME ($(du -h "$BACKUP_DIR/$FILENAME" | cut -f1))"
else
echo "[$(date)] Backup FAILED" >&2
rm -f "$BACKUP_DIR/$FILENAME"
exit 1
fi
# Rotate: keep last 14 backups
ls -1t "$BACKUP_DIR"/pedquiz_*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm --

View file

@ -66,6 +66,25 @@ services:
- redis_data:/data
restart: unless-stopped
db-backup:
image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: pedquiz
POSTGRES_USER: pedquiz
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
SCHEDULE: "@daily"
BACKUP_KEEP_DAYS: 14
BACKUP_KEEP_WEEKS: 4
BACKUP_KEEP_MONTHS: 6
HEALTHCHECK_PORT: 8080
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
volumes:
uploads_data:
chroma_data:

View file

@ -16,8 +16,18 @@
"react-router-dom": "^6.22.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"@tinymce/tinymce-react": "^5.1.1",
"tinymce": "^7.6.1"
"@milkdown/core": "^7.6.2",
"@milkdown/ctx": "^7.6.2",
"@milkdown/preset-commonmark": "^7.6.2",
"@milkdown/preset-gfm": "^7.6.2",
"@milkdown/plugin-listener": "^7.6.2",
"@milkdown/plugin-history": "^7.6.2",
"@milkdown/react": "^7.6.2",
"@milkdown/theme-nord": "^7.6.2",
"@milkdown/plugin-math": "^7.5.9",
"katex": "^0.16.11",
"remark-math": "^6.0.0",
"rehype-katex": "^7.0.1"
},
"devDependencies": {
"@types/react": "^18.2.55",

View file

@ -94,7 +94,6 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/question-bank', label: 'Question Bank' },
{ to: '/flashcards', label: 'Flashcards' },
{ to: '/courses', label: 'Courses' },
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
{ to: '/settings', label: '⚙ Settings' },
] : []

View file

@ -1,47 +1,114 @@
import { useRef } from 'react'
import { Editor } from '@tinymce/tinymce-react'
import { useState, useRef, useCallback } from 'react'
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBulletListCommand, wrapInOrderedListCommand, wrapInBlockquoteCommand, insertHrCommand } from '@milkdown/preset-commonmark'
import { gfm, insertTableCommand, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
import { history, undoCommand, redoCommand } from '@milkdown/plugin-history'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { math } from '@milkdown/plugin-math'
import { nord } from '@milkdown/theme-nord'
import { Milkdown, MilkdownProvider, useEditor, useInstance } from '@milkdown/react'
import { callCommand } from '@milkdown/utils'
import '@milkdown/theme-nord/style.css'
import 'katex/dist/katex.min.css'
// Self-hosted TinyMCE no API key needed
import 'tinymce/tinymce'
import 'tinymce/models/dom'
import 'tinymce/themes/silver'
import 'tinymce/icons/default'
import 'tinymce/skins/ui/oxide/skin.min.css'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/link'
import 'tinymce/plugins/image'
import 'tinymce/plugins/table'
import 'tinymce/plugins/code'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/autolink'
function Toolbar() {
const [, getEditor] = useInstance()
const call = (cmd, payload) => {
const editor = getEditor()
if (editor) editor.action(callCommand(cmd, payload))
}
export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) {
const editorRef = useRef(null)
const btn = { background: 'none', border: '1px solid #e2e8f0', borderRadius: 4, padding: '4px 8px', cursor: 'pointer', fontSize: '0.82rem', color: '#475569', lineHeight: 1 }
const sep = { width: 1, height: 20, background: '#e2e8f0', margin: '0 4px' }
return (
<Editor
onInit={(evt, editor) => { editorRef.current = editor }}
value={value}
onEditorChange={(content) => onChange(content)}
init={{
height,
menubar: false,
plugins: 'lists link image table code fullscreen autolink',
toolbar: 'undo redo | blocks | bold italic underline | bullist numlist | link image table | code fullscreen',
placeholder,
skin: false,
content_css: false,
content_style: `
body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #e2e8f0; background: #1e293b; padding: 12px; }
a { color: #60a5fa; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #475569; padding: 6px 10px; }
img { max-width: 100%; }
`,
branding: false,
promotion: false,
license_key: 'gpl',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '6px 10px', borderBottom: '1px solid #e2e8f0', background: '#f8fafc', flexWrap: 'wrap' }}>
<button style={btn} onClick={() => call(undoCommand.key)} title="Undo (Ctrl+Z)"></button>
<button style={btn} onClick={() => call(redoCommand.key)} title="Redo (Ctrl+Shift+Z)"></button>
<div style={sep} />
<button style={{ ...btn, fontWeight: 700 }} onClick={() => call(toggleStrongCommand.key)} title="Bold (Ctrl+B)">B</button>
<button style={{ ...btn, fontStyle: 'italic' }} onClick={() => call(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">I</button>
<button style={{ ...btn, textDecoration: 'line-through' }} onClick={() => call(toggleStrikethroughCommand.key)} title="Strikethrough">S</button>
<div style={sep} />
<button style={btn} onClick={() => call(wrapInBulletListCommand.key)} title="Bullet list">&#8226; List</button>
<button style={btn} onClick={() => call(wrapInOrderedListCommand.key)} title="Numbered list">1. List</button>
<button style={btn} onClick={() => call(wrapInBlockquoteCommand.key)} title="Blockquote">&gt; Quote</button>
<div style={sep} />
<button style={btn} onClick={() => call(insertTableCommand.key)} title="Insert table">Table</button>
<button style={btn} onClick={() => call(insertHrCommand.key)} title="Horizontal rule"> HR</button>
</div>
)
}
function MilkdownEditor({ value, onChange, placeholder }) {
const isInternalChange = useRef(false)
const lastValue = useRef(value)
useEditor((root) =>
Editor.make()
.config(nord)
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, value || '')
const l = ctx.get(listenerCtx)
l.markdownUpdated((_, md) => {
isInternalChange.current = true
lastValue.current = md
onChange(md)
})
})
.use(commonmark)
.use(gfm)
.use(history)
.use(math)
.use(listener),
[])
return <Milkdown />
}
export default function RichEditor({ value, onChange, height = 350, placeholder = 'Start writing...' }) {
const [editorKey, setEditorKey] = useState(0)
const isInternalChange = useRef(false)
const lastSetValue = useRef(value)
if (value !== lastSetValue.current && !isInternalChange.current) {
lastSetValue.current = value
setEditorKey(k => k + 1)
}
isInternalChange.current = false
const handleChange = useCallback((md) => {
isInternalChange.current = true
lastSetValue.current = md
onChange(md)
}, [onChange])
return (
<div className="milkdown-wrapper" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
<MilkdownProvider key={editorKey}>
<Toolbar />
<MilkdownEditor value={value} onChange={handleChange} placeholder={placeholder} />
</MilkdownProvider>
<style>{`
.milkdown-wrapper .milkdown { min-height: ${height}px; }
.milkdown-wrapper .editor { padding: 16px 20px; outline: none; font-family: Inter, -apple-system, sans-serif; font-size: 14px; line-height: 1.7; color: #1e293b; }
.milkdown-wrapper .editor > * { margin-top: 0; margin-bottom: 0.5em; }
.milkdown-wrapper .editor h2 { font-size: 1.3em; margin-top: 1.2em; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
.milkdown-wrapper .editor h3 { font-size: 1.1em; margin-top: 1em; }
.milkdown-wrapper .editor table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.milkdown-wrapper .editor td, .milkdown-wrapper .editor th { border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }
.milkdown-wrapper .editor th { background: #f8fafc; font-weight: 600; }
.milkdown-wrapper .editor a { color: #2563eb; text-decoration: underline; }
.milkdown-wrapper .editor img { max-width: 100%; border-radius: 6px; }
.milkdown-wrapper .editor blockquote { border-left: 3px solid #2563eb; padding-left: 12px; color: #64748b; margin: 8px 0; }
.milkdown-wrapper .editor code { background: #f1f5f9; padding: 2px 5px; border-radius: 4px; font-size: 0.88em; }
.milkdown-wrapper .editor pre { background: #f1f5f9; padding: 12px; border-radius: 8px; overflow-x: auto; }
.milkdown-wrapper .editor hr { border: none; border-top: 1px solid #e2e8f0; margin: 1.5em 0; }
.milkdown-wrapper .editor ul, .milkdown-wrapper .editor ol { padding-left: 1.5em; }
.milkdown-wrapper .editor li { margin-bottom: 0.3em; }
.milkdown-wrapper .editor p:empty::before { content: '${placeholder}'; color: #94a3b8; }
`}</style>
</div>
)
}

View file

@ -350,3 +350,20 @@ body {
.quiz-sidebar { display: none; }
.quiz-nav-toggle { display: inline-flex; }
}
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
.lesson-content h1, .lesson-content h2, .lesson-content h3, .lesson-content h4 { margin-top: 1.2em; margin-bottom: 0.5em; }
.lesson-content h2 { font-size: 1.3rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.lesson-content h3 { font-size: 1.1rem; }
.lesson-content h4 { font-size: 0.95rem; }
.lesson-content p { margin: 0.6em 0; }
.lesson-content ul, .lesson-content ol { padding-left: 1.5em; margin: 0.5em 0; }
.lesson-content li { margin-bottom: 0.3em; }
.lesson-content table { width: 100%; border-collapse: collapse; margin: 1em 0; font-size: 0.88rem; }
.lesson-content th, .lesson-content td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
.lesson-content th { background: var(--bg); font-weight: 600; }
.lesson-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
.lesson-content blockquote { border-left: 3px solid var(--primary); padding-left: 12px; margin: 1em 0; color: var(--text-muted); }
.lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; }
.lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; }
.lesson-content strong { font-weight: 700; }

View file

@ -41,13 +41,16 @@ export default function AccountPage() {
return
}
await api.put('/auth/me', payload)
const res = await api.put('/auth/me', payload)
if (res.data.password_warning) {
setError(res.data.password_warning)
}
setSuccess('Account updated successfully')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
// Refresh user info
const res = await api.get('/auth/me')
await api.get('/auth/me')
// Update local token display by refreshing
window.location.reload()
} catch (err) {

View file

@ -1,5 +1,12 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams, useNavigate, Link } from 'react-router-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeRaw from 'rehype-raw'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const LESSON_ICONS = {
@ -45,10 +52,12 @@ export default function CourseDetailPage() {
// Build completed/in-progress sets from enrollment lesson progress if available
const completed = new Set()
const inProgress = new Set()
if (data.enrollment?.lesson_progress) {
for (const lp of data.enrollment.lesson_progress) {
if (lp.status === 'completed') completed.add(lp.lesson_id)
else if (lp.status === 'in_progress') inProgress.add(lp.lesson_id)
if (data.my_enrollment?.lesson_progress) {
const lp = data.my_enrollment.lesson_progress
// lesson_progress is { lesson_id: { status, ... } }
for (const [lessonId, info] of Object.entries(lp)) {
if (info.status === 'completed') completed.add(parseInt(lessonId))
else if (info.status === 'in_progress') inProgress.add(parseInt(lessonId))
}
}
setCompletedLessons(completed)
@ -124,8 +133,11 @@ export default function CourseDetailPage() {
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
const enrolled = !!course.enrollment
const progressPct = course.enrollment?.progress_pct || 0
const { user } = useAuth()
const enrolled = !!course.my_enrollment
const isCreator = user && (course.user_id === user.id || user.role === 'admin')
const canView = enrolled || isCreator // creators can preview without enrolling
const progressPct = course.my_enrollment?.progress_pct || 0
return (
<div>
@ -167,7 +179,7 @@ export default function CourseDetailPage() {
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
{!enrolled && (
{!canView && (
<div className="card">
<div className="empty-state">
<p>Enroll in this course to access modules and lessons.</p>
@ -175,8 +187,14 @@ export default function CourseDetailPage() {
</div>
)}
{isCreator && !enrolled && (
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--primary)', borderRadius: 8, padding: '8px 14px', marginBottom: 12, fontSize: '0.85rem', color: 'var(--primary)' }}>
Preview mode you are viewing as the course creator.
</div>
)}
{/* Modules accordion */}
{enrolled && course.modules && course.modules.length > 0 && (
{canView && course.modules && course.modules.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: activeLesson ? 16 : 0 }}>
{course.modules.map(mod => {
const isExpanded = !!expandedModules[mod.id]
@ -204,12 +222,12 @@ export default function CourseDetailPage() {
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
{lessons.length > 0 && ` \u00B7 ${completedCount}/${lessons.length} completed`}
{lessons.length > 0 && ` &middot; ${completedCount}/${lessons.length} completed`}
</div>
</div>
</div>
{lessons.length > 0 && completedCount === lessons.length && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>\u2713 Done</span>
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>&#10003; Done</span>
)}
</div>
@ -244,14 +262,14 @@ export default function CourseDetailPage() {
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
{lesson.lesson_type === 'live_session' && lesson.scheduled_at && (
<span> \u00B7 {new Date(lesson.scheduled_at).toLocaleString()}</span>
{lesson.lesson_type === 'live_session' && lesson.live_session_start && (
<span> &middot; {new Date(lesson.live_session_start).toLocaleString()}</span>
)}
</div>
</div>
</div>
<div>
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>\u2713</span>}
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>&#10003;</span>}
</div>
</div>
)
@ -264,12 +282,12 @@ export default function CourseDetailPage() {
</div>
)}
{enrolled && (!course.modules || course.modules.length === 0) && (
{canView && (!course.modules || course.modules.length === 0) && (
<div className="card"><div className="empty-state">This course has no modules yet.</div></div>
)}
{/* Lesson viewer */}
{enrolled && activeLesson && (
{canView && activeLesson && (
<div className="card" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<div>
@ -283,12 +301,12 @@ export default function CourseDetailPage() {
<div style={{ display: 'flex', gap: 8 }}>
{!completedLessons.has(activeLesson.id) && (
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
Mark Complete \u2713
Mark Complete &#10003;
</button>
)}
{completedLessons.has(activeLesson.id) && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
\u2713 Completed
&#10003; Completed
</span>
)}
<button className="btn btn-secondary btn-sm" onClick={() => { setActiveLesson(null); setLessonContent(null) }}>Close</button>
@ -298,8 +316,14 @@ export default function CourseDetailPage() {
{loadingLesson && <div className="loading"><div className="spinner" /></div>}
{!loadingLesson && activeLesson.lesson_type === 'text' && (
<div style={{ lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{activeLesson.content_text || 'No content available.'}
<div className="lesson-content" style={{ lineHeight: 1.7 }}>
{activeLesson.content_text ? (
<Markdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeRaw, rehypeKatex]}>
{activeLesson.content_text}
</Markdown>
) : (
<p style={{ color: 'var(--text-muted)' }}>No content available.</p>
)}
</div>
)}
@ -327,23 +351,73 @@ export default function CourseDetailPage() {
)}
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ marginBottom: 16, color: 'var(--text-muted)' }}>This lesson contains a quiz.</p>
{activeLesson.quiz_id ? (
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}`)}>
Start Quiz
</button>
) : (
<p style={{ color: 'var(--text-muted)' }}>No quiz linked to this lesson.</p>
<div style={{ padding: 20 }}>
{activeLesson.quiz_id ? (() => {
const attempts = activeLesson.quiz_attempts || []
const maxAttempts = activeLesson.quiz_max_attempts
const attemptsLeft = maxAttempts ? maxAttempts - attempts.length : null
const canAttempt = !maxAttempts || attempts.length < maxAttempts
const lastAttempt = attempts[0]
return (
<div>
{activeLesson.quiz_title && (
<h3 style={{ fontSize: '1.05rem', marginBottom: 8 }}>{activeLesson.quiz_title}</h3>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', margin: 0 }}>
{activeLesson.quiz_questions_per_attempt
? `${activeLesson.quiz_questions_per_attempt} questions`
: `${activeLesson.quiz_questions_count || '?'} questions`}
{activeLesson.quiz_mode === 'timed' && activeLesson.quiz_time_limit ? ` · ${activeLesson.quiz_time_limit} min` : ''}
{activeLesson.quiz_mode === 'timed' ? ' · Exam mode' : ' · Study mode'}
</p>
{maxAttempts && (
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginTop: 4 }}>
{attemptsLeft > 0 ? `${attemptsLeft} attempt${attemptsLeft !== 1 ? 's' : ''} remaining` : 'No attempts remaining'}
</p>
)}
</div>
{canAttempt && (
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
</button>
)}
</div>
{attempts.length > 0 && (
<div>
<h4 style={{ fontSize: '0.88rem', marginBottom: 8 }}>Your Attempts</h4>
{attempts.map((a, i) => (
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)' }}>
{i === 0 ? 'Latest' : `Attempt ${attempts.length - i}`}
{a.completed_at ? ` · ${new Date(a.completed_at).toLocaleDateString()}` : ''}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: a.percentage >= 75 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)' }}>
{a.score}/{a.total} ({a.percentage}%)
</span>
{a.completed_at && activeLesson.quiz_allow_review !== 0 && (
<Link to={`/results/${a.id}?return_to=/courses/${courseId}`} style={{ fontSize: '0.78rem', color: 'var(--primary)', textDecoration: 'none' }}>Review</Link>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})() : (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>No quiz linked to this lesson.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
<div style={{ textAlign: 'center', padding: 20 }}>
{activeLesson.scheduled_at && (
{activeLesson.live_session_start && (
<p style={{ marginBottom: 12, fontSize: '0.95rem' }}>
Scheduled: <strong>{new Date(activeLesson.scheduled_at).toLocaleString()}</strong>
Scheduled: <strong>{new Date(activeLesson.live_session_start).toLocaleString()}</strong>
</p>
)}
{activeLesson.live_session_url ? (

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react'
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
@ -6,6 +6,11 @@ import ConfirmButton from '../components/ConfirmButton'
const RichEditor = lazy(() => import('../components/RichEditor'))
function stripHtml(html) {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim()
}
const LESSON_TYPES = [
{ value: 'text', label: 'Text / Markdown', icon: '📝' },
{ value: 'video', label: 'Video', icon: '🎬' },
@ -17,38 +22,52 @@ const LESSON_TYPES = [
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
// --- Question Bank Mini-Browser ---
function QuestionBankBrowser({ onQuizCreated }) {
function QuestionBankBrowser({ courseId, onQuizCreated }) {
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('')
const [filterCatIds, setFilterCatIds] = useState([])
const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
const [selectedTagIds, setSelectedTagIds] = useState([])
const [showTags, setShowTags] = useState(false)
const [search, setSearch] = useState('')
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [selected, setSelected] = useState(new Set())
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const debounceRef = useRef(null)
useEffect(() => {
api.get('/question-categories/').then(r => setCategories(r.data)).catch(() => {})
api.get('/tags').then(r => setTags(r.data)).catch(() => {})
}, [])
const fetchQuestions = useCallback(async () => {
const fetchQuestions = useCallback(async (q = search, catIds = filterCatIds, tagIds = selectedTagIds) => {
setLoading(true)
try {
const params = { limit: 20 }
if (categoryId) params.category_id = categoryId
if (search.trim()) params.q = search.trim()
const params = { limit: 30 }
if (q.trim()) params.q = q.trim()
if (catIds.length > 0) params.category_ids = catIds.join(',')
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
const res = await api.get('/questions/bank', { params })
setQuestions(res.data.questions || res.data || [])
setTotal(res.data.total || 0)
} catch {
setQuestions([])
} finally {
setLoading(false)
}
}, [categoryId, search])
}, [search, filterCatIds, selectedTagIds])
const catIdsKey = filterCatIds.join(',')
const tagIdsKey = selectedTagIds.join(',')
useEffect(() => {
fetchQuestions()
}, [fetchQuestions])
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => fetchQuestions(search, filterCatIds, selectedTagIds), 300)
return () => clearTimeout(debounceRef.current)
}, [search, catIdsKey, tagIdsKey])
const toggleTag = (tagId) => setSelectedTagIds(prev => prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId])
const toggle = (qId) => {
setSelected(prev => {
@ -58,15 +77,30 @@ function QuestionBankBrowser({ onQuizCreated }) {
})
}
// Quiz settings
const [quizTitle, setQuizTitle] = useState('')
const [quizMode, setQuizMode] = useState('learning')
const [quizTimeLimit, setQuizTimeLimit] = useState('')
const [quizMaxAttempts, setQuizMaxAttempts] = useState('')
const [quizQuestionsPerAttempt, setQuizQuestionsPerAttempt] = useState('')
const [quizAllowReview, setQuizAllowReview] = useState(true)
const createQuiz = async () => {
if (selected.size === 0) return
setCreating(true)
setError('')
try {
const res = await api.post('/questions/from-bank', {
const res = await api.post(`/courses/${courseId}/quiz`, {
question_ids: [...selected],
title: quizTitle.trim() || undefined,
mode: quizMode,
time_limit_minutes: quizTimeLimit ? parseInt(quizTimeLimit) : null,
max_attempts: quizMaxAttempts ? parseInt(quizMaxAttempts) : null,
questions_per_attempt: quizQuestionsPerAttempt ? parseInt(quizQuestionsPerAttempt) : null,
allow_review: quizAllowReview,
})
onQuizCreated(res.data.quiz_id || res.data.id)
setQuizTitle('')
onQuizCreated(res.data.id)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to create quiz')
} finally {
@ -74,51 +108,253 @@ function QuestionBankBrowser({ onQuizCreated }) {
}
}
// Inline add question
const [showAddQ, setShowAddQ] = useState(false)
const [newQ, setNewQ] = useState({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' })
const [addingQ, setAddingQ] = useState(false)
const submitNewQuestion = async () => {
const validOpts = newQ.options.filter(o => o.trim())
if (!newQ.text.trim() || validOpts.length < 2 || newQ.correctIdx < 0) return
setAddingQ(true)
try {
const res = await api.post('/questions/create', {
question_text: newQ.text,
question_type: 'mcq',
options: validOpts,
correct_answer: newQ.options[newQ.correctIdx],
explanation: newQ.explanation || null,
})
// Auto-select the new question
setSelected(prev => new Set([...prev, res.data.id]))
setNewQ({ text: '', options: ['', '', '', ''], correctIdx: -1, explanation: '' })
setShowAddQ(false)
fetchQuestions()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add question')
} finally {
setAddingQ(false)
}
}
const hasTags = tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 14, marginTop: 8, background: 'var(--bg)' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} style={{ flex: 1, minWidth: 140 }}>
<option value="">All categories</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginTop: 8, background: 'var(--bg)' }}>
{/* Search */}
<div style={{ position: 'relative', marginBottom: 10 }}>
<span style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '0.85rem' }}>🔍</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search questions..."
style={{ flex: 2, minWidth: 160 }}
style={{ width: '100%', padding: '9px 12px 9px 32px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.88rem', background: 'var(--input-bg)', color: 'var(--text)', boxSizing: 'border-box' }}
/>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', marginBottom: 10 }}>
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Loading...</div>}
{!loading && questions.length === 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No questions found.</div>
)}
{questions.map(q => (
<label key={q.id} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 4px', cursor: 'pointer', fontSize: '0.85rem', borderBottom: '1px solid var(--border)' }}>
<input
type="checkbox"
checked={selected.has(q.id)}
onChange={() => toggle(q.id)}
style={{ marginTop: 3 }}
/>
<span style={{ flex: 1 }}>{q.question_text?.slice(0, 120) || `Question #${q.id}`}</span>
</label>
{/* Category chips */}
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap', marginBottom: 10 }}>
<button className={`btn btn-sm ${filterCatIds.length === 0 ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterCatIds([])} style={{ fontSize: '0.72rem', padding: '3px 10px' }}>All</button>
{categories.map(cat => (
<button key={cat.id} className={`btn btn-sm ${filterCatIds.includes(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilterCatIds(prev => prev.includes(cat.id) ? prev.filter(c => c !== cat.id) : [...prev, cat.id])}
style={{ fontSize: '0.72rem', padding: '3px 10px' }}>
{cat.name} <span style={{ opacity: 0.6 }}>({cat.question_count})</span>
</button>
))}
{/* Active tag chips */}
{selectedTagIds.map(tid => {
const allTags = [...(tags.subjects || []), ...(tags.diseases || []), ...(tags.keywords || [])]
const tag = allTags.find(t => t.id === tid)
if (!tag) return null
const color = (tags.subjects || []).find(t => t.id === tid) ? '#8b5cf6' : (tags.diseases || []).find(t => t.id === tid) ? '#ef4444' : '#0ea5e9'
return (
<button key={`tag-${tid}`} onClick={() => toggleTag(tid)}
style={{ background: color, color: '#fff', border: 'none', fontSize: '0.72rem', padding: '3px 10px', borderRadius: 12, cursor: 'pointer', fontWeight: 600 }}>
{tag.name}
</button>
)
})}
</div>
{/* Tag browser toggle */}
{hasTags && (
<div style={{ marginBottom: 10 }}>
<button className="btn btn-sm btn-secondary" onClick={() => setShowTags(v => !v)}
style={{ fontSize: '0.72rem', padding: '3px 10px' }}>
Tags {showTags ? '▲' : '▼'}
</button>
{showTags && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, marginTop: 8 }}>
{[
{ 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 || [] },
].map(sec => {
if (sec.items.length === 0) return null
return (
<div key={sec.key} style={{ background: 'var(--card-bg)', borderRadius: 8, padding: 8, border: '1px solid var(--border)' }}>
<div style={{ fontSize: '0.68rem', fontWeight: 700, color: sec.color, textTransform: 'uppercase', marginBottom: 4 }}>{sec.label}</div>
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{sec.items.map(tag => {
const sel = selectedTagIds.includes(tag.id)
return (
<button key={tag.id} onClick={() => toggleTag(tag.id)}
style={{ display: 'block', width: '100%', textAlign: 'left', background: sel ? sec.color : 'transparent',
color: sel ? '#fff' : 'var(--text)', border: 'none', borderRadius: 4, padding: '2px 6px',
fontSize: '0.72rem', cursor: 'pointer', marginBottom: 1 }}>
{tag.name} <span style={{ opacity: 0.5 }}>({tag.count})</span>
</button>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Results count */}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: 6 }}>
{total > 0 ? `${total} questions found` : ''} {selected.size > 0 ? `· ${selected.size} selected` : ''}
</div>
{/* Question list */}
<div style={{ maxHeight: 420, overflowY: 'auto', marginBottom: 10 }}>
{loading && questions.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', padding: 12 }}>Loading...</div>}
{!loading && questions.length === 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', padding: 12 }}>No questions found.</div>
)}
{questions.map(q => {
const isSelected = selected.has(q.id)
const plainText = stripHtml(q.question_text)
return (
<div key={q.id} onClick={() => toggle(q.id)} style={{
padding: '12px 14px', marginBottom: 6, borderRadius: 10, cursor: 'pointer',
background: isSelected ? 'var(--option-sel-bg)' : 'var(--card-bg)',
border: `2px solid ${isSelected ? 'var(--primary)' : 'var(--border)'}`,
transition: 'all 0.15s',
}}>
{/* Top row: checkbox + category + source */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<input type="checkbox" checked={isSelected} onChange={() => {}}
style={{ accentColor: 'var(--primary)', flexShrink: 0, width: 16, height: 16 }} />
{q.question_category_name && (
<span style={{ fontSize: '0.7rem', fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--primary)', color: '#fff' }}>
{q.question_category_name}
</span>
)}
{q.quiz_title && <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{q.quiz_title}</span>}
</div>
{/* Question text */}
<div style={{ fontSize: '0.88rem', color: 'var(--text)', lineHeight: 1.6, marginBottom: q.options ? 8 : 0 }}>
{plainText.length > 220 ? plainText.slice(0, 220) + '...' : plainText}
</div>
{/* Options — each on its own row */}
{q.options && q.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{q.options.map((opt, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'baseline', gap: 6,
fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: 1.4,
}}>
<span style={{
fontWeight: 700, fontSize: '0.72rem', color: 'var(--primary)',
background: 'var(--option-sel-bg)', borderRadius: 4, padding: '1px 5px',
flexShrink: 0,
}}>{String.fromCharCode(65 + i)}</span>
<span>{opt.length > 80 ? opt.slice(0, 80) + '...' : opt}</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 8, fontSize: '0.85rem' }}>{error}</div>}
<button
className="btn btn-primary btn-sm"
onClick={createQuiz}
disabled={selected.size === 0 || creating}
>
{creating ? 'Creating...' : `Create Quiz from ${selected.size} Question${selected.size !== 1 ? 's' : ''}`}
</button>
{/* Add question inline */}
{showAddQ && (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--card-bg)' }}>
<div style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: 8 }}>Add New Question</div>
<textarea value={newQ.text} onChange={e => setNewQ(q => ({ ...q, text: e.target.value }))}
placeholder="Question text..." rows={2}
style={{ width: '100%', padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem', resize: 'vertical', boxSizing: 'border-box', marginBottom: 8 }} />
{newQ.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<button onClick={() => setNewQ(q => ({ ...q, correctIdx: i }))}
style={{ width: 24, height: 24, borderRadius: '50%', border: `2px solid ${newQ.correctIdx === i ? 'var(--correct-fg)' : 'var(--border)'}`,
background: newQ.correctIdx === i ? 'var(--correct-fg)' : 'transparent', color: newQ.correctIdx === i ? '#fff' : 'var(--text-muted)',
fontSize: '0.7rem', fontWeight: 700, cursor: 'pointer', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{String.fromCharCode(65 + i)}
</button>
<input value={opt} onChange={e => { const opts = [...newQ.options]; opts[i] = e.target.value; setNewQ(q => ({ ...q, options: opts })) }}
placeholder={`Option ${String.fromCharCode(65 + i)}`}
style={{ flex: 1, padding: '6px 10px', border: `1px solid ${newQ.correctIdx === i ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6,
background: newQ.correctIdx === i ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem' }} />
</div>
))}
<textarea value={newQ.explanation} onChange={e => setNewQ(q => ({ ...q, explanation: e.target.value }))}
placeholder="Explanation (optional)" rows={1}
style={{ width: '100%', padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem', resize: 'vertical', boxSizing: 'border-box', marginTop: 6 }} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn btn-primary btn-sm" onClick={submitNewQuestion} disabled={addingQ || !newQ.text.trim() || newQ.correctIdx < 0}>
{addingQ ? 'Adding...' : 'Add Question'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAddQ(false)}>Cancel</button>
</div>
</div>
)}
{/* Quiz settings */}
{selected.size > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10, padding: '10px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<input type="text" value={quizTitle} onChange={e => setQuizTitle(e.target.value)}
placeholder="Quiz title (optional)" style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)', width: '100%' }} />
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<select value={quizMode} onChange={e => setQuizMode(e.target.value)}
style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="learning">Study mode</option>
<option value="timed">Timed exam</option>
</select>
{quizMode === 'timed' && (
<input type="number" min={1} value={quizTimeLimit} onChange={e => setQuizTimeLimit(e.target.value)}
placeholder="Time (min)" style={{ width: 90, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
)}
<input type="number" min={1} value={quizMaxAttempts} onChange={e => setQuizMaxAttempts(e.target.value)}
placeholder="Max attempts" title="Leave blank for unlimited"
style={{ width: 110, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<input type="number" min={1} max={selected.size} value={quizQuestionsPerAttempt} onChange={e => setQuizQuestionsPerAttempt(e.target.value)}
placeholder={`Questions per try (${selected.size} pool)`} title="Random subset each attempt. Leave blank for all."
style={{ width: 180, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.82rem', color: 'var(--text)', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={quizAllowReview} onChange={e => setQuizAllowReview(e.target.checked)} />
Allow review
</label>
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-primary btn-sm"
onClick={createQuiz}
disabled={selected.size === 0 || creating}
>
{creating ? 'Creating...' : `Create Quiz from ${selected.size} Question${selected.size !== 1 ? 's' : ''}`}
</button>
{!showAddQ && (
<button className="btn btn-secondary btn-sm" onClick={() => setShowAddQ(true)}>+ Add Question</button>
)}
</div>
</div>
)
}
@ -139,8 +375,16 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
const [saving, setSaving] = useState(false)
const [aiPrompt, setAiPrompt] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiModels, setAiModels] = useState([])
const [aiModelId, setAiModelId] = useState('')
const [error, setError] = useState('')
useEffect(() => {
api.get('/admin/models/available?task=teach').then(r => {
setAiModels(r.data || [])
}).catch(() => {})
}, [])
const handleSave = async () => {
if (!title.trim()) { setError('Title is required'); return }
setSaving(true)
@ -185,13 +429,23 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
const endpoint = lesson?.id
? `/courses/${courseId}/lessons/${lesson.id}/ai-generate`
: `/courses/${courseId}/ai-generate`
const res = await api.post(endpoint, {
const body = {
prompt: aiPrompt || `Write lesson content about: ${title}`,
action,
existing_content: action === 'refine' ? contentText : undefined,
})
existing_content: contentText || undefined,
}
if (aiModelId) body.model_id = aiModelId
const res = await api.post(endpoint, body)
if (res.data.generated_content) {
setContentText(res.data.generated_content)
let content = res.data.generated_content
// Auto-set title if empty use the prompt or first heading
if (!title.trim()) {
const headingMatch = content.match(/^#\s+(?:Lesson\s+Title:\s*)?(.+)/m)
setTitle(headingMatch ? headingMatch[1].trim() : aiPrompt.trim().slice(0, 80))
}
// Remove any "# Lesson Title: ..." line from content
content = content.replace(/^#\s+Lesson\s+Title:\s*.+\n+---\n*/m, '')
setContentText(content)
}
} catch (err) {
setError(err.response?.data?.detail || 'AI generation failed')
@ -244,10 +498,17 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
type="text"
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
placeholder="Describe what AI should write..."
style={{ flex: 1, minWidth: 200, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}
placeholder="Describe what AI should write or modify..."
style={{ flex: 1, minWidth: 180, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}
onKeyDown={e => e.key === 'Enter' && aiGenerate('generate')}
/>
{aiModels.length > 0 && (
<select value={aiModelId} onChange={e => setAiModelId(e.target.value)}
style={{ padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.82rem', maxWidth: 180 }}>
<option value="">Default model</option>
{aiModels.map(m => <option key={m.model_id} value={m.model_id}>{m.name || m.model_id}</option>)}
</select>
)}
<button
className="btn btn-primary btn-sm"
onClick={() => aiGenerate('generate')}
@ -257,20 +518,14 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
</button>
{contentText.trim() && (
<button
className="btn btn-secondary btn-sm"
onClick={() => aiGenerate('refine')}
disabled={aiLoading}
>
AI Refine
</button>
)}
</div>
)}
{!lesson?.id && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
Save the lesson first, then use AI Generate to create content.
</p>
)}
className="btn btn-secondary btn-sm"
onClick={() => aiGenerate('refine')}
disabled={aiLoading}
>
AI Refine
</button>
)}
</div>
</div>
)}
@ -301,9 +556,9 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
<div className="form-group">
<label>Upload Document</label>
<input type="file" accept=".pdf,.doc,.docx,.ppt,.pptx" onChange={e => setFile(e.target.files[0])} />
{lesson?.file_name && !file && (
{lesson?.local_file_path && !file && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2, display: 'block' }}>
Current: {lesson.file_name}
Current: {lesson.local_file_path}
</span>
)}
</div>
@ -315,11 +570,19 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
<label>Quiz</label>
{quizId ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '0.88rem' }}>Linked quiz #{quizId}</span>
<span style={{ fontSize: '0.88rem', color: 'var(--correct-fg)', fontWeight: 600 }}>
{lesson?.quiz_title || `Quiz #${quizId}`} linked
</span>
<button className="btn btn-secondary btn-sm" onClick={() => setQuizId(null)}>Change</button>
</div>
) : (
<QuestionBankBrowser onQuizCreated={(id) => setQuizId(id)} />
<QuestionBankBrowser courseId={courseId} onQuizCreated={(id) => {
setQuizId(id)
// Auto-save quiz_id to lesson if lesson already exists
if (lesson?.id) {
api.put(`/courses/${courseId}/lessons/${lesson.id}`, { quiz_id: id }).catch(() => {})
}
}} />
)}
</div>
)}
@ -418,15 +681,11 @@ export default function CourseEditorPage() {
clearMessages()
setSaving(true)
try {
const payload = { title: title.trim(), description: description.trim() }
await api.put(`/courses/${courseId}`, { title: title.trim(), description: description.trim() })
if (thumbnail) {
const fd = new FormData()
fd.append('title', payload.title)
fd.append('description', payload.description)
fd.append('thumbnail', thumbnail)
await api.put(`/courses/${courseId}`, fd)
} else {
await api.put(`/courses/${courseId}`, payload)
fd.append('file', thumbnail)
await api.post(`/courses/${courseId}/thumbnail`, fd)
}
setSuccess('Course saved.')
setThumbnail(null)
@ -438,15 +697,16 @@ export default function CourseEditorPage() {
}
}
const publishCourse = async () => {
const togglePublish = async () => {
clearMessages()
setPublishing(true)
const action = course.status === 'published' ? 'unpublish' : 'publish'
try {
await api.post(`/courses/${courseId}/publish`)
setSuccess('Course published!')
await api.post(`/courses/${courseId}/${action}`)
setSuccess(action === 'publish' ? 'Course published!' : 'Course unpublished (draft).')
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to publish')
setError(err.response?.data?.detail || `Failed to ${action}`)
} finally {
setPublishing(false)
}
@ -563,6 +823,11 @@ export default function CourseEditorPage() {
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/courses')}>&larr; Back to Courses</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${courseId}`)}>Preview Course</button>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
@ -605,9 +870,9 @@ export default function CourseEditorPage() {
onChange={e => setThumbnail(e.target.files[0])}
disabled={!isOwner}
/>
{course.thumbnail_url && !thumbnail && (
{course.thumbnail_path && !thumbnail && (
<img
src={course.thumbnail_url}
src={course.thumbnail_path}
alt="Thumbnail"
style={{ marginTop: 8, maxHeight: 120, borderRadius: 8, objectFit: 'cover' }}
/>
@ -619,11 +884,10 @@ export default function CourseEditorPage() {
<button className="btn btn-primary" onClick={saveCourse} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{course.status !== 'published' && (
<button className="btn btn-secondary" onClick={publishCourse} disabled={publishing}>
{publishing ? 'Publishing...' : 'Publish'}
</button>
)}
<button className={`btn ${course.status === 'published' ? 'btn-secondary' : 'btn-primary'}`}
onClick={togglePublish} disabled={publishing} style={course.status === 'published' ? { color: 'var(--wrong-fg)' } : {}}>
{publishing ? '...' : course.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</div>
)}
</div>
@ -690,7 +954,7 @@ export default function CourseEditorPage() {
onChange={e => setEditModuleTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') updateModule(mod.id); if (e.key === 'Escape') setEditingModuleId(null) }}
onClick={e => e.stopPropagation()}
style={{ flex: 1 }}
style={{ flex: 1, padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.95rem', fontWeight: 600 }}
autoFocus
/>
) : (
@ -763,7 +1027,7 @@ export default function CourseEditorPage() {
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderBottom: lesIdx < lessons.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<span style={{ fontSize: '0.9rem' }}>{typeIcon(lesson.type)}</span>
<span style={{ fontSize: '0.9rem' }}>{typeIcon(lesson.lesson_type)}</span>
<span style={{ flex: 1, fontSize: '0.9rem' }}>
{lesson.title}
{lesson.is_required === false && (
@ -771,7 +1035,7 @@ export default function CourseEditorPage() {
)}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{lesson.type}
{lesson.lesson_type}
{lesson.duration_minutes ? ` · ${lesson.duration_minutes}m` : ''}
</span>
@ -831,6 +1095,87 @@ export default function CourseEditorPage() {
)
})}
</div>
{/* Enrollees */}
{isOwner && <EnrolleesSection courseId={courseId} />}
</div>
)
}
function EnrolleesSection({ courseId }) {
const [enrollees, setEnrollees] = useState([])
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState(false)
const load = () => {
setLoading(true)
api.get(`/courses/${courseId}/enrollees`).then(r => setEnrollees(r.data)).catch(() => {}).finally(() => setLoading(false))
}
return (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>Enrollees</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => { setExpanded(v => !v); if (!expanded) load() }}>
{expanded ? 'Hide' : 'Show'}
</button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
try {
const res = await api.get(`/courses/${courseId}/enrollees/export`, { responseType: 'blob' })
const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'enrollees.csv'; a.click()
window.URL.revokeObjectURL(url)
} catch {}
}}>Export CSV</button>
</div>
</div>
{expanded && (
<div style={{ marginTop: 16 }}>
{loading && <div className="loading"><div className="spinner" /></div>}
{!loading && enrollees.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.88rem' }}>No enrollees yet.</p>
)}
{enrollees.map(e => (
<div key={e.user_id} style={{ padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<span style={{ fontWeight: 600, fontSize: '0.92rem' }}>{e.name}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginLeft: 8 }}>{e.email}</span>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: '0.85rem' }}>
<span style={{ color: e.progress_pct >= 100 ? 'var(--correct-fg)' : 'var(--text-muted)' }}>
{e.progress_pct}% complete
</span>
<span style={{ color: 'var(--text-muted)' }}>
{e.completed_lessons} lessons
</span>
</div>
</div>
<div className="progress-bar" style={{ height: 4, marginTop: 6 }}>
<div className="fill" style={{ width: `${e.progress_pct}%` }} />
</div>
{e.quiz_scores && e.quiz_scores.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{e.quiz_scores.map(qs => (
<span key={qs.quiz_id} style={{
fontSize: '0.78rem', padding: '3px 8px', borderRadius: 6,
background: qs.percentage >= 75 ? 'var(--correct-bg)' : qs.percentage >= 50 ? '#fef3c7' : 'var(--wrong-bg)',
color: qs.percentage >= 75 ? 'var(--correct-fg)' : qs.percentage >= 50 ? '#92400e' : 'var(--wrong-fg)',
fontWeight: 600,
}}>
{qs.quiz_title}: {qs.percentage}% ({qs.attempts} attempt{qs.attempts !== 1 ? 's' : ''})
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View file

@ -4,6 +4,8 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function CoursesPage() {
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [tab, setTab] = useState('browse')
const [courses, setCourses] = useState([])
const [total, setTotal] = useState(0)
@ -16,7 +18,6 @@ export default function CoursesPage() {
const [newTitle, setNewTitle] = useState('')
const [newDescription, setNewDescription] = useState('')
const [creating, setCreating] = useState(false)
const { user } = useAuth()
const navigate = useNavigate()
const debounceRef = useRef(null)
const LIMIT = 12
@ -75,8 +76,9 @@ export default function CoursesPage() {
const togglePublish = async (courseId, currentlyPublished) => {
try {
await api.patch(`/courses/${courseId}`, { is_published: !currentlyPublished })
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, is_published: !currentlyPublished } : c))
const action = currentlyPublished ? 'unpublish' : 'publish'
await api.post(`/courses/${courseId}/${action}`)
setCreatedCourses(prev => prev.map(c => c.id === courseId ? { ...c, status: currentlyPublished ? 'draft' : 'published' } : c))
} catch { }
}
@ -113,7 +115,7 @@ export default function CoursesPage() {
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse</button>
<button className={`btn btn-sm ${tab === 'enrolled' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('enrolled')}>Enrolled</button>
<button className={`btn btn-sm ${tab === 'created' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('created')}>Created by Me</button>
{isModerator && <button className={`btn btn-sm ${tab === 'created' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('created')}>Created by Me</button>}
</div>
</div>
</div>
@ -134,7 +136,11 @@ export default function CoursesPage() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{courses.map(course => (
<div key={course.id} className="card" style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div key={course.id} className="card" style={{ padding: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{course.thumbnail_path && (
<img src={course.thumbnail_path} alt="" style={{ width: '100%', height: 140, objectFit: 'cover' }} />
)}
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 0, lineHeight: 1.5 }}>
@ -146,11 +152,17 @@ export default function CoursesPage() {
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
{course.is_enrolled ? (
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Continue</button>
<>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/courses/${course.id}`)}>Start</button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
try { await api.delete(`/courses/${course.id}/enroll`); setCourses(prev => prev.map(c => c.id === course.id ? { ...c, is_enrolled: false, enrolled_count: Math.max(0, (c.enrolled_count || 1) - 1) } : c)) } catch {}
}}>Unenroll</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => enroll(course.id)}>Enroll</button>
)}
</div>
</div>
</div>
))}
</div>
@ -187,10 +199,10 @@ export default function CoursesPage() {
{course.module_count || 0} module{(course.module_count || 0) !== 1 ? 's' : ''} &middot; by {course.creator_name || 'Unknown'}
</div>
<div className="progress-bar" style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div className="fill" style={{ width: `${course.progress || 0}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
<div className="fill" style={{ width: `${course.progress_pct || 0}%`, height: '100%', background: 'var(--primary)', borderRadius: 3, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
{course.progress || 0}% complete
{course.progress_pct || 0}% complete
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0, alignItems: 'flex-start' }}>
@ -246,10 +258,10 @@ export default function CoursesPage() {
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{course.title}</h3>
<span style={{
fontSize: '0.72rem', padding: '2px 8px', borderRadius: 10, fontWeight: 600,
background: course.is_published ? '#d1fae5' : '#fef3c7',
color: course.is_published ? '#065f46' : '#92400e',
background: course.status === 'published' ? '#d1fae5' : '#fef3c7',
color: course.status === 'published' ? '#065f46' : '#92400e',
}}>
{course.is_published ? 'Published' : 'Draft'}
{course.status === 'published' ? 'Published' : 'Draft'}
</span>
</div>
{course.description && (
@ -263,8 +275,8 @@ export default function CoursesPage() {
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/courses/${course.id}/edit`)}>Edit</button>
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.is_published)}>
{course.is_published ? 'Unpublish' : 'Publish'}
<button className="btn btn-secondary btn-sm" onClick={() => togglePublish(course.id, course.status === 'published')}>
{course.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
{deletingId === course.id ? (
<div style={{ display: 'flex', gap: 4 }}>

View file

@ -14,7 +14,6 @@ function greeting(name) {
export default function DashboardPage() {
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [documents, setDocuments] = useState([])
const [stats, setStats] = useState(null)
const [history, setHistory] = useState([])
const [selectedQuizId, setSelectedQuizId] = useState(null)
@ -39,12 +38,10 @@ export default function DashboardPage() {
useEffect(() => {
const promises = [
isModerator ? api.get('/documents/') : Promise.resolve({ data: [] }),
api.get('/attempts/stats/dashboard'),
api.get('/attempts/history'),
]
Promise.all(promises).then(([docsRes, statsRes, histRes]) => {
setDocuments(docsRes.data)
Promise.all(promises).then(([statsRes, histRes]) => {
setStats(statsRes.data)
setHistory(histRes.data)
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
@ -66,7 +63,6 @@ export default function DashboardPage() {
{stats && (
<div className="stats-grid">
{[
...(isModerator ? [{ value: stats.total_documents, label: 'Documents' }] : []),
{ value: stats.total_quizzes, label: 'Quizzes' },
{ value: stats.total_attempts, label: 'Attempts' },
{ value: `${stats.average_score}%`, label: 'Avg Score' },
@ -159,28 +155,8 @@ export default function DashboardPage() {
)}
{isModerator && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Documents</h2>
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
</div>
{documents.length === 0 ? (
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
) : (
documents.map(doc => (
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="section-item">
<div>
<strong>{doc.original_filename}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
</div>
</div>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</div>
</Link>
))
)}
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
</div>
)}
</div>

View file

@ -19,7 +19,10 @@ export default function ForgotPasswordPage() {
if (err.response?.status === 429) {
setError('Too many reset requests. Please wait before trying again.')
} else {
setError(err.response?.data?.detail || 'Something went wrong.')
const detail = err.response?.data?.detail
if (typeof detail === 'string') setError(detail)
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input.')
else setError('Something went wrong.')
}
} finally {
setLoading(false)

View file

@ -178,6 +178,7 @@ function AuthModal({ mode, onClose, onSwitch }) {
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [registered, setRegistered] = useState(false)
const [passwordWarning, setPasswordWarning] = useState('')
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
@ -202,6 +203,9 @@ function AuthModal({ mode, onClose, onSwitch }) {
setLoading(true)
try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
if (res.data.password_warning) {
setPasswordWarning(res.data.password_warning)
}
if (res.data.requires_verification) {
setRegistered(true)
} else {
@ -210,7 +214,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
navigate('/')
}
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed')
const detail = err.response?.data?.detail
if (typeof detail === 'string') setError(detail)
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
else setError('Registration failed')
} finally { setLoading(false) }
}
@ -259,6 +266,11 @@ function AuthModal({ mode, onClose, onSwitch }) {
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
</p>
{passwordWarning && (
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 12, fontSize: '0.82rem', color: '#92400e', textAlign: 'left' }}>
{passwordWarning}
</div>
)}
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
Go to Sign In
</button>
@ -456,7 +468,7 @@ export default function LandingPage() {
))}
</div>
<a
href="https://peds.danvics.com"
href="https://scribe.pedshub.com"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
@ -521,7 +533,7 @@ export default function LandingPage() {
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
<a href="https://scribe.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
</div>
</div>
</footer>

View file

@ -53,7 +53,10 @@ export default function LoginPage() {
if (err.response?.status === 403) {
setUnverified(true)
} else {
setError(err.response?.data?.detail || 'Login failed')
const detail = err.response?.data?.detail
if (typeof detail === 'string') setError(detail)
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
else setError('Login failed')
}
} finally {
setLoading(false)

View file

@ -6,21 +6,37 @@ import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
const RichEditor = lazy(() => import('../components/RichEditor'))
function QuestionStudyModal({ question, onClose }) {
/** Strip HTML tags for plain text display (truncated cards). */
function stripHtml(html) {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim()
}
function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }) {
const [answered, setAnswered] = useState(null)
return (
<>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{onToggleFavorite && (
<button onClick={() => 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 ? '⭐' : '☆'}
</button>
)}
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
</div>
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>{question.question_text}</p>
<div style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: question.question_text }} />
{question.image_path && (
<div style={{ margin: '0 0 14px' }}>
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
@ -210,15 +226,8 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
setSaving(true); setError('')
try {
const payload = { ...form, question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null }
// Update via quiz edit endpoint (uses source quiz for routing)
const res = await api.patch(`/quizzes/${question.quiz_id}/questions/${question.id}`, payload)
// Also update category if changed
if (form.question_category_id !== (question.question_category_id || '')) {
await api.patch(`/questions/${question.id}/category`, null, {
params: { category_id: form.question_category_id ? parseInt(form.question_category_id) : '' }
})
}
onSaved({ ...question, ...res.data, question_category_id: payload.question_category_id,
const res = await api.patch(`/questions/${question.id}`, payload)
onSaved({ ...question, ...res.data,
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
onClose()
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
@ -283,27 +292,72 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
}
function CreateQuestionModal({ categories, onCreated, onClose }) {
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', explanation: '', question_category_id: '' })
const LETTERS = ['A','B','C','D','E','F','G','H']
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', correctIndex: -1, explanation: '', question_category_id: '', image_path: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [imageTab, setImageTab] = useState('upload') // 'upload' | 'bank'
const [imageBank, setImageBank] = useState([])
const [uploadingImage, setUploadingImage] = useState(false)
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
setForm(f => ({ ...f, options: updated, correct_answer: f.correctIndex === i ? val : f.correct_answer }))
}
const setCorrectIndex = (i) => {
setForm(f => ({ ...f, correctIndex: i, correct_answer: f.options[i] }))
}
const removeOption = (i) => {
setForm(f => {
const opts = f.options.filter((_, idx) => idx !== i)
let ci = f.correctIndex
if (ci === i) ci = -1
else if (ci > i) ci--
return { ...f, options: opts, correctIndex: ci, correct_answer: ci >= 0 ? opts[ci] : '' }
})
}
const loadImageBank = async () => {
try {
const res = await api.get('/questions/images')
setImageBank(res.data)
} catch {}
}
const handleImageUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingImage(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await api.post('/questions/upload-image', fd)
setForm(f => ({ ...f, image_path: res.data.image_path }))
} catch (err) {
setError(err.response?.data?.detail || 'Image upload failed')
} finally {
setUploadingImage(false)
}
}
const save = async () => {
if (!form.question_text.trim()) return setError('Question text is required')
const validOpts = form.options.filter(o => o.trim())
if (validOpts.length < 2) return setError('At least 2 options are required')
if (form.correctIndex < 0 || !form.correct_answer) return setError('Select the correct answer')
setSaving(true); setError('')
try {
const payload = {
question_text: form.question_text,
question_type: form.question_type,
options: form.options.filter(o => o.trim()),
options: validOpts,
correct_answer: form.correct_answer,
explanation: form.explanation || null,
question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null,
image_path: form.image_path || null,
}
const res = await api.post('/questions/create', payload)
onCreated(res.data)
@ -314,42 +368,127 @@ function CreateQuestionModal({ categories, onCreated, onClose }) {
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 560, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
<h2 style={{ marginBottom: 16 }}>Create Question</h2>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
rows={3} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 28, maxWidth: 720, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: '1.15rem', margin: 0 }}>Create Question</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
<div className="form-group">
<label>Category</label>
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Question Text</label>
<Suspense fallback={<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} rows={4}
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
<RichEditor value={form.question_text} onChange={(v) => setForm(f => ({ ...f, question_text: v }))} height={180} placeholder="Write the question..." />
</Suspense>
</div>
<div className="form-group">
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Category</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}
style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }}>
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}>
<option value="">None</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{/* Image */}
<div className="form-group">
<label>Options</label>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem', width: 20 }}>{String.fromCharCode(65 + i)}</span>
<input value={opt} onChange={e => setOption(i, e.target.value)} placeholder={`Option ${String.fromCharCode(65 + i)}`}
style={{ flex: 1, padding: '7px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.85rem' }} />
<input type="radio" name="correct" checked={form.correct_answer === opt && opt !== ''} onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
title="Mark as correct" />
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Image (optional)</label>
{form.image_path ? (
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 8 }}>
<img src={`/uploads/${form.image_path}`} alt="Question"
style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 8, border: '1px solid var(--border)' }} />
<button onClick={() => setForm(f => ({ ...f, image_path: '' }))}
style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}></button>
</div>
))}
<button className="btn btn-sm btn-secondary" onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
) : (
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<button className={`btn btn-sm ${imageTab === 'upload' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setImageTab('upload')} style={{ fontSize: '0.78rem' }}>Upload New</button>
<button className={`btn btn-sm ${imageTab === 'bank' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setImageTab('bank'); loadImageBank() }} style={{ fontSize: '0.78rem' }}>Image Bank</button>
</div>
{imageTab === 'upload' && (
<div>
<input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploadingImage}
style={{ fontSize: '0.85rem' }} />
{uploadingImage && <span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginLeft: 8 }}>Uploading...</span>}
</div>
)}
{imageTab === 'bank' && (
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, padding: 4 }}>
{imageBank.length === 0 && <div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', gridColumn: '1/-1' }}>No images in bank yet. Upload one first.</div>}
{imageBank.map((img, i) => (
<div key={i} onClick={() => setForm(f => ({ ...f, image_path: img.image_path }))}
style={{ cursor: 'pointer', border: '2px solid var(--border)', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'} onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
<img src={img.url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
onError={e => e.target.style.display = 'none'} />
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="form-group">
<label>Explanation (optional)</label>
<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
rows={2} style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>
Options click the letter to mark as correct
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.options.map((opt, i) => {
const isCorrect = form.correctIndex === i
return (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => setCorrectIndex(i)} title={isCorrect ? 'Correct answer' : 'Click to mark as correct'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32,
borderRadius: '50%', flexShrink: 0, fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer',
border: isCorrect ? '2px solid var(--correct-fg)' : '2px solid var(--border)',
background: isCorrect ? 'var(--correct-fg)' : 'transparent',
color: isCorrect ? 'white' : 'var(--text-muted)',
transition: 'all 0.15s',
}}>
{LETTERS[i]}
</button>
<input value={opt} onChange={e => setOption(i, e.target.value)}
placeholder={`Option ${LETTERS[i]}`}
style={{
flex: 1, padding: '10px 14px',
border: `1.5px solid ${isCorrect ? 'var(--correct-bd)' : 'var(--border)'}`,
borderRadius: 8, background: isCorrect ? 'var(--correct-bg)' : 'var(--input-bg)',
color: 'var(--text)', fontSize: '0.9rem', fontFamily: 'inherit',
}} />
{form.options.length > 2 && (
<button onClick={() => removeOption(i)} title="Remove option"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', padding: '4px 6px' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
</button>
)}
</div>
)
})}
</div>
{form.options.length < 8 && (
<button className="btn btn-sm btn-secondary" style={{ marginTop: 8 }}
onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving || !form.question_text.trim() || !form.correct_answer}>
<div className="form-group">
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Explanation (optional)</label>
<Suspense fallback={<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} rows={3}
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
<RichEditor value={form.explanation} onChange={(v) => setForm(f => ({ ...f, explanation: v }))} height={150} placeholder="Explain why this is the correct answer..." />
</Suspense>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? 'Creating...' : 'Create Question'}
</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
@ -370,6 +509,7 @@ export default function QuestionBankPage() {
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)
@ -388,6 +528,10 @@ export default function QuestionBankPage() {
const [classifyStatus, setClassifyStatus] = useState(null)
const [classifySteps, setClassifySteps] = useState([])
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState(null)
const debounceRef = useRef(null)
const classifyPollRef = useRef(null)
const { user } = useAuth()
@ -412,6 +556,7 @@ export default function QuestionBankPage() {
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])
@ -426,7 +571,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, searchMode, pageSize, tagIdsKey])
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey])
const toggleTag = (tagId) => {
setSelectedTagIds(prev => {
@ -535,6 +680,26 @@ export default function QuestionBankPage() {
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: err.response?.data?.detail || 'Import failed' })
} finally {
setImporting(false)
}
}
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
try {
@ -579,13 +744,55 @@ export default function QuestionBankPage() {
)
})()}
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />}
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)}
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
onClose={() => setEditQuestion(null)} />}
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
{showCreateQuestion && <CreateQuestionModal categories={categories} onCreated={(q) => { setQuestions(prev => [q, ...prev]); setTotal(t => t + 1) }} onClose={() => setShowCreateQuestion(false)} />}
{/* Import CSV/Excel Modal */}
{showImport && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 28, maxWidth: 520, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Import Questions</h2>
<button onClick={() => setShowImport(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
Upload a CSV or Excel (.xlsx) file with columns: <strong>question_text</strong>, <strong>option_a</strong> through <strong>option_e</strong>, <strong>correct_answer</strong> (A/B/C/D/E), <strong>explanation</strong>, <strong>category</strong>.
</p>
<a href="/api/questions/import/sample" download style={{ fontSize: '0.85rem', color: 'var(--primary)', marginBottom: 14, display: 'inline-block' }}>
Download sample CSV template
</a>
<div style={{ marginTop: 12, marginBottom: 14 }}>
<input type="file" accept=".csv,.xlsx,.xls" onChange={e => { setImportFile(e.target.files[0]); setImportResult(null) }}
style={{ fontSize: '0.88rem' }} />
</div>
{importResult && !importResult.error && (
<div className="alert" style={{ background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginBottom: 12, fontSize: '0.85rem' }}>
Imported <strong>{importResult.imported}</strong> of {importResult.total_rows} questions.
{importResult.errors?.length > 0 && (
<div style={{ marginTop: 6, fontSize: '0.78rem', color: 'var(--text-muted)' }}>
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
</div>
)}
</div>
)}
{importResult?.error && (
<div className="alert alert-error" style={{ marginBottom: 12, fontSize: '0.85rem' }}>{importResult.error}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={handleImport} disabled={!importFile || importing}>
{importing ? 'Importing...' : 'Upload & Import'}
</button>
<button className="btn btn-secondary" onClick={() => setShowImport(false)}>Close</button>
</div>
</div>
</div>
)}
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
@ -600,6 +807,7 @@ export default function QuestionBankPage() {
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div>
</div>
@ -622,8 +830,10 @@ export default function QuestionBankPage() {
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
<button className={`btn btn-sm ${showMyQuestions ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowMyQuestions(v => !v); setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>My Questions</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false) }}>Uncategorized</button>
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false); setShowMyQuestions(false) }}>Uncategorized</button>
{categories.map(cat => {
const isActive = filterCatIds.includes(cat.id)
return (
@ -772,7 +982,7 @@ export default function QuestionBankPage() {
)}
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 6 : 0 }}>
{q.question_text.slice(0, 200)}{q.question_text.length > 200 ? '' : ''}
{stripHtml(q.question_text).slice(0, 200)}{stripHtml(q.question_text).length > 200 ? '...' : ''}
</p>
{q.options && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
@ -804,7 +1014,26 @@ export default function QuestionBankPage() {
{favorites.includes(q.id) ? '⭐' : '☆'}
</button>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
{(q.user_id === user?.id || isModerator) && (
<button className="btn btn-sm btn-secondary" style={{ fontSize: '0.72rem' }}
onClick={async () => {
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'}</button>
)}
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
{isModerator && <button className="btn btn-sm btn-secondary" style={{ color: 'var(--wrong-fg)' }}
onClick={async () => {
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(err.response?.data?.detail || 'Delete failed', { title: 'Error' }) }
}}>Delete</button>}
</div>
</div>
))}

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
@ -73,6 +73,29 @@ function TimerDisplay({ seconds, total }) {
)
}
function CourseQuizStart({ quiz, onStart }) {
const mode = quiz.mode === 'timed' ? 'exam' : 'study'
return (
<div style={{ maxWidth: 480, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
{quiz.questions_per_attempt || quiz.questions_count} questions
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
</p>
<p style={{ fontSize: '0.88rem', marginBottom: 20 }}>
{mode === 'exam'
? 'This is a timed exam — answers are hidden until you submit.'
: 'Study mode — answers and explanations shown as you go.'}
</p>
<button className="btn btn-primary" onClick={() => onStart(mode, '', quiz.time_limit_minutes || null)}>
Begin Quiz
</button>
</div>
</div>
)
}
function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
@ -134,6 +157,8 @@ const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const returnTo = searchParams.get('return_to')
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [quiz, setQuiz] = useState(null)
@ -170,10 +195,11 @@ export default function QuizPage() {
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {}
const aid = saved.attempt_id || saved.attemptId || ''
// For study mode, load quiz with correct answers BEFORE showing
if (mode === 'study') {
try {
const quizRes = await api.get(`/quizzes/${id}?study=true`)
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
setQuiz(quizRes.data)
} catch (err) {
console.error('Failed to load quiz:', err)
@ -183,7 +209,7 @@ export default function QuizPage() {
} else {
// For exam mode, ensure quiz data is loaded
try {
const quizRes = await api.get(`/quizzes/${id}`)
const quizRes = await api.get(`/quizzes/${id}${aid ? `?attempt_id=${aid}` : ''}`)
setQuiz(quizRes.data)
} catch (err) {
console.error('Failed to load quiz:', err)
@ -263,16 +289,17 @@ export default function QuizPage() {
// (prevents mobile race condition where quiz renders without correct_answer)
try {
let quizData = quiz
if (mode === 'study') {
// Need answers/explanations fetch the full study version
const quizRes = await api.get(`/quizzes/${id}?study=true`)
quizData = quizRes.data
setQuiz(quizData)
}
// Exam mode reuses the already-loaded quiz (no answers needed)
// Start attempt first (may select random question subset)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
const aid = attemptRes.data.id
// Fetch quiz with attempt_id for question pool filtering
let quizData = quiz
const studyParam = (mode === 'study') ? '&study=true' : ''
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
quizData = quizRes.data
setQuiz(quizData)
const mins = timerMinutes || quizData.time_limit_minutes
const now = new Date().toISOString()
setStartedAt(now)
@ -341,7 +368,11 @@ const timerStarted = timeLeft !== null
})),
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } })
if (returnTo) {
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
} else {
navigate(`/results/${attemptId}`, { state: { result: res.data } })
}
} catch (err) {
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
} finally { setSubmitting(false) }
@ -362,6 +393,8 @@ const timerStarted = timeLeft !== null
<div className="spinner" style={{ margin: '0 auto 16px' }} />
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz</div>
</div>
) : returnTo ? (
<CourseQuizStart quiz={quiz} onStart={startQuiz} />
) : (
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
)}
@ -415,11 +448,13 @@ const timerStarted = timeLeft !== null
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
Your progress is saved resume from where you left off.
Your progress is saved you can resume from where you left off.
{timeLeft !== null && (
<><br/><br/>
<strong style={{ color: '#f59e0b' }}> Timer continues:</strong> This quiz is timed.
If you leave, the timer keeps counting and will auto-submit when it expires.
<strong style={{ color: '#f59e0b' }}> Timer is still running.</strong> If time expires before you return, the quiz will be auto-submitted with your current answers.
{returnTo && quiz?.max_attempts && (
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</>
)}
</>
)}
</p>
@ -460,7 +495,7 @@ const timerStarted = timeLeft !== null
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget('/')} title="Save progress and exit">
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
Suspend
</button>
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}

View file

@ -36,6 +36,7 @@ export default function RegisterPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [passwordWarning, setPasswordWarning] = useState('')
const [turnstileToken, setTurnstileToken] = useState('')
const { loginWithToken } = useAuth()
@ -45,6 +46,9 @@ export default function RegisterPage() {
setLoading(true)
try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
if (res.data.password_warning) {
setPasswordWarning(res.data.password_warning)
}
if (res.data.requires_verification) {
setDone(true)
} else {
@ -53,7 +57,12 @@ export default function RegisterPage() {
window.location.href = '/'
}
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed')
const detail = err.response?.data?.detail
if (typeof detail === 'string') setError(detail)
else if (Array.isArray(detail)) {
const hasEmail = detail.some(e => e.loc?.includes('email'))
setError(hasEmail ? 'Invalid email address.' : 'Please check your input and try again.')
} else setError('Registration failed')
} finally {
setLoading(false)
}
@ -71,6 +80,11 @@ export default function RegisterPage() {
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
Click the link in the email to activate your account, then you can log in.
</p>
{passwordWarning && (
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 16, fontSize: '0.85rem', color: '#92400e', textAlign: 'left' }}>
{passwordWarning}
</div>
)}
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
<div className="auth-link" style={{ marginTop: 16 }}>
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>

View file

@ -19,9 +19,12 @@ export default function ResetPasswordPage() {
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
setLoading(true)
try {
await api.post('/auth/reset-password', { token, new_password: password })
const res = await api.post('/auth/reset-password', { token, new_password: password })
if (res.data.password_warning) {
setError(res.data.password_warning)
}
setSuccess(true)
setTimeout(() => navigate('/login'), 2500)
setTimeout(() => navigate('/login'), 3500)
} catch (err) {
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
} finally {

View file

@ -1,22 +1,27 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'
import api from '../api/client'
export default function ResultsPage() {
const { id } = useParams()
const location = useLocation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const returnTo = searchParams.get('return_to')
const [result, setResult] = useState(location.state?.result || null)
const [loading, setLoading] = useState(!result)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const isCourseQuiz = result?.course_id != null
const reviewAllowed = result?.allow_review !== false
const deleteAttempt = async () => {
if (!confirmDelete) { setConfirmDelete(true); return }
setDeleting(true)
try {
await api.delete(`/attempts/${result.id}`)
navigate('/quizzes')
navigate(returnTo || '/quizzes')
} catch {
setDeleting(false)
setConfirmDelete(false)
@ -27,7 +32,7 @@ export default function ResultsPage() {
if (!result) {
api.get(`/attempts/${id}`)
.then(res => setResult(res.data))
.catch(() => navigate('/quizzes'))
.catch(() => navigate(returnTo || '/quizzes'))
.finally(() => setLoading(false))
}
}, [id])
@ -37,7 +42,7 @@ export default function ResultsPage() {
const pct = result.percentage
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
const correct = result.answers?.filter(a => a.is_correct).length ?? result.score
const correct = result.answers?.length > 0 ? result.answers.filter(a => a.is_correct).length : result.score
const total = result.total_questions
return (
@ -71,131 +76,148 @@ export default function ResultsPage() {
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
<Link to="/" className="btn btn-secondary">Dashboard</Link>
{confirmDelete ? (
{isCourseQuiz ? (
<>
<button
onClick={deleteAttempt}
disabled={deleting}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
{deleting ? 'Deleting…' : 'Confirm Delete'}
</button>
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
{returnTo && (
<Link to={returnTo} className="btn btn-primary">Back to Course</Link>
)}
</>
) : (
<button
onClick={deleteAttempt}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
Delete Attempt
</button>
<>
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
<Link to="/" className="btn btn-secondary">Dashboard</Link>
{confirmDelete ? (
<>
<button
onClick={deleteAttempt}
disabled={deleting}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
{deleting ? 'Deleting…' : 'Confirm Delete'}
</button>
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
</>
) : (
<button
onClick={deleteAttempt}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
Delete Attempt
</button>
)}
</>
)}
</div>
</div>
</div>
{/* Summary row */}
{result.answers && result.answers.length > 0 && (
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{[
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
].map(s => (
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
</div>
))}
</div>
)}
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
{(!result.answers || result.answers.length === 0) ? (
<div className="card"><div className="empty-state">No answers recorded.</div></div>
) : result.answers.map((ans, idx) => {
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return (
<div className={`review-card ${cardClass}`} key={idx}>
{/* Question header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Question {idx + 1}
</span>
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
{ans.question_text}
</p>
{/* Question review — only if allowed */}
{reviewAllowed && result.answers && result.answers.length > 0 && (
<>
{/* Summary row */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{[
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
].map(s => (
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
</div>
<span style={{
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
}}>
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
</span>
</div>
))}
</div>
{/* Options with letter badges */}
{ans.options && ans.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{ans.options.map((opt, i) => {
const isCorrect = opt === ans.correct_answer
const isUser = opt === ans.user_answer
const isWrong = isUser && !isCorrect
const letter = String.fromCharCode(65 + i)
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
fontSize: '0.73rem', fontWeight: 700,
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
}}>{letter}</span>
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Correct answer</span>}
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
</div>
)
})}
</div>
)}
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
{/* Fill-blank */}
{(!ans.options || ans.options.length === 0) && (
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
{result.answers.map((ans, idx) => {
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return (
<div className={`review-card ${cardClass}`} key={idx}>
{/* Question header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
<div style={{ flex: 1 }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Question {idx + 1}
</span>
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
{ans.question_text}
</p>
</div>
<span style={{
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
}}>
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
</span>
</div>
{!ans.is_correct && (
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
<strong>Correct answer:</strong> {ans.correct_answer}
{/* Options with letter badges */}
{ans.options && ans.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{ans.options.map((opt, i) => {
const isCorrect = opt === ans.correct_answer
const isUser = opt === ans.user_answer
const isWrong = isUser && !isCorrect
const letter = String.fromCharCode(65 + i)
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
fontSize: '0.73rem', fontWeight: 700,
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
}}>{letter}</span>
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Correct answer</span>}
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}> Your answer</span>}
</div>
)
})}
</div>
)}
{/* Fill-blank */}
{(!ans.options || ans.options.length === 0) && (
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
<div style={{ padding: '8px 14px', borderRadius: 6, marginBottom: 6, background: ans.is_correct ? 'var(--correct-bg)' : 'var(--wrong-bg)' }}>
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
</div>
{!ans.is_correct && (
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
<strong>Correct answer:</strong> {ans.correct_answer}
</div>
)}
</div>
)}
{/* Explanation */}
{ans.explanation && (
<div className="explanation">
<strong>Explanation</strong>
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
</div>
)}
</div>
)}
)
})}
</>
)}
{/* Explanation */}
{ans.explanation && (
<div className="explanation">
<strong>Explanation</strong>
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
</div>
)}
</div>
)
})}
{!reviewAllowed && (
<div className="card" style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Answer review is not available for this quiz.</p>
</div>
)}
</div>
)
}

View file

@ -227,6 +227,43 @@ function AdminSection() {
)
}
function DocumentsSection() {
const [documents, setDocuments] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/documents/').then(res => setDocuments(res.data)).catch(() => {}).finally(() => setLoading(false))
}, [])
return (
<Section title="Documents">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: 0 }}>
Your uploaded PDFs for quiz and flashcard generation.
</p>
<Link to="/upload" className="btn btn-primary btn-sm">Upload PDF</Link>
</div>
{loading && <div className="loading"><div className="spinner" /></div>}
{!loading && documents.length === 0 && (
<div className="empty-state" style={{ padding: '16px 0' }}><p>No documents yet.</p></div>
)}
{documents.map(doc => (
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="section-item">
<div style={{ flex: 1, minWidth: 0 }}>
<strong style={{ fontSize: '0.9rem' }}>{doc.original_filename}</strong>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} &middot; {new Date(doc.uploaded_at).toLocaleDateString()}
</div>
</div>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</div>
</Link>
))}
</Section>
)
}
export default function SettingsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
@ -240,6 +277,7 @@ export default function SettingsPage() {
<ProfileSection user={user} />
<AppearanceSection />
{isModerator && <NextcloudSection />}
{isModerator && <DocumentsSection />}
{(isAdmin || isModerator) && <AdminSection />}
</div>
)

View file

@ -236,7 +236,6 @@ export default function UploadPage() {
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
Upload & Process
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
</div>
)}
</div>