Major platform update: pgvector search, multi-provider TTS, settings page, CLI

Features:
- Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE)
- AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim)
- Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS
- Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts)
- Good morning/afternoon greeting on dashboard
- manage.py CLI: reset-password, list-users, reembed
- Email verification enforced: register no longer returns JWT for unverified users
- Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets
- TTS button: loading/playing states, voice selector locked during playback
- TTS auto-stops when navigating between questions
- Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed
- OpenAI Alloy as default TTS voice; favicon added
- SMTP configured via smtp2go; password reset rate limiting (3/hour)
- PostgreSQL upgraded to pgvector/pgvector:pg16

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-31 18:03:10 +02:00
parent b876f13fac
commit 47ba213ae3
54 changed files with 3572 additions and 882 deletions

172
README.md Normal file
View file

@ -0,0 +1,172 @@
# 🩺 PedQuiz
AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automatically extract MCQ questions with AI, and take quizzes with text-to-speech support and semantic search.
## Features
- **PDF → Quiz**: Upload PREP PDFs, AI extracts questions, answers, and explanations
- **Quiz Modes**: Study (instant feedback) and Exam (timed, scored)
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs — voice selection per quiz
- **Semantic Search**: pgvector + AWS Titan Embed — finds questions by meaning, not just keywords
- **Email Verification**: Required before first login; password reset via email
- **Role system**: Admin / Moderator / User
- **Nextcloud Integration**: Browse and import PDFs from your Nextcloud in Upload page
- **Themes**: Default and Markdown (GitHub-style)
## Stack
| Layer | Tech |
|---|---|
| Frontend | React + React Router, plain CSS, Nginx |
| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector |
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock) |
| Embeddings | AWS Bedrock Titan Embed V2 via LiteLLM proxy |
| Document vectors | ChromaDB |
| TTS | OpenAI, AWS Polly, ElevenLabs |
| Queue | Celery + Redis |
| Email | SMTP (smtp2go) |
## Quick Start
```bash
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git
cd pdf-quiz-generator
# Configure environment
cp backend/.env.example backend/.env # edit with your keys
docker compose up -d
```
Frontend available at `http://localhost:8081`. The first registered user becomes admin automatically.
## Environment Variables
Create `backend/.env`:
```env
# Database
DATABASE_URL=postgresql://pedquiz:<password>@postgres:5432/pedquiz
SECRET_KEY=<random-32-char-string>
# Redis
REDIS_URL=redis://redis:6379/0
# LLM (for question extraction)
LITELLM_MODEL=openai/claude-haiku-4.5
LITELLM_API_KEY=<your-litellm-or-openai-key>
LITELLM_API_BASE=https://your-litellm-proxy.com # or leave empty for direct OpenAI
LITELLM_EMBEDDING_MODEL=openai/titan-embed-v2
# OpenAI (for TTS — uses api.openai.com directly, not proxy)
OPENAI_API_KEY=<openai-api-key>
# AWS (for Polly TTS + Bedrock embedding fallback)
AWS_ACCESS_KEY_ID=<key>
AWS_SECRET_ACCESS_KEY=<secret>
AWS_REGION=us-east-1
AWS_BEDROCK_REGION=us-east-1
# ElevenLabs TTS (optional)
ELEVENLABS_API_KEY=<key>
# Google Cloud TTS (optional)
GOOGLE_TTS_API_KEY=<key>
# Email
MAIL_SERVER=mail.smtp2go.com
MAIL_PORT=587
MAIL_USERNAME=<smtp2go-username>
MAIL_PASSWORD=<smtp2go-password>
MAIL_FROM=noreply@yourdomain.com
MAIL_STARTTLS=true
# App
APP_URL=https://your-domain.com
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data
```
## CLI Management
```bash
# Reset a user's password (e.g. locked-out admin)
docker compose exec backend python manage.py reset-password admin@example.com NewPassword123
# List all users and verification status
docker compose exec backend python manage.py list-users
# Regenerate all question embeddings (e.g. after switching embedding model)
docker compose exec backend python manage.py reembed
```
## Architecture
```
Browser
Nginx (frontend + API proxy)
├─► React SPA (static)
└─► FastAPI backend
├─ PostgreSQL (pgvector) ← users, quizzes, questions + embeddings
├─ ChromaDB ← document page chunks for quiz generation
├─ Redis ← Celery task queue
├─ Celery workers ← background PDF processing, emails
├─ LiteLLM proxy ← Claude/Gemini/GPT for extraction + embeddings
├─ AWS Bedrock ← Polly TTS, Titan embed fallback
└─ OpenAI ← TTS (direct, not via proxy)
```
### Search
Quiz search uses **hybrid retrieval**:
1. **Semantic** — embed the query with Titan Embed V2, cosine similarity against all questions via pgvector HNSW index
2. **Keyword** — PostgreSQL `ILIKE` on question text and options
3. Results merged and ranked — semantic matches shown first by score, keyword-only matches appended
### TTS Providers
| Provider | Model IDs | Key Needed |
|---|---|---|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1:echo`, `tts-1:shimmer`, `tts-1:onyx`, `tts-1:fable`, `tts-1-hd:*` | `OPENAI_API_KEY` |
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy`, `polly/Brian` | `AWS_ACCESS_KEY_ID` + `polly:SynthesizeSpeech` IAM permission |
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |
## Project Structure
```
├── backend/
│ ├── app/
│ │ ├── main.py # App startup, seeding, pgvector setup
│ │ ├── config.py # Settings (pydantic-settings)
│ │ ├── models/ # SQLAlchemy models
│ │ ├── routers/ # FastAPI route handlers
│ │ ├── services/
│ │ │ ├── ai_service.py # LLM extraction + TTS
│ │ │ ├── embedding_service.py # pgvector embeddings
│ │ │ ├── vector_service.py # ChromaDB (document pages)
│ │ │ ├── quiz_service.py # Quiz creation pipeline
│ │ │ └── email_service.py # Email templates + sending
│ │ └── utils/
│ ├── manage.py # CLI: reset-password, list-users, reembed
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── pages/ # Dashboard, Quiz, Results, Settings, Search …
│ │ ├── components/ # Navbar, LineChart
│ │ └── context/ # AuthContext, ThemeContext
│ ├── nginx.conf
│ └── Dockerfile
└── docker-compose.yml
```
## Deployment Notes
- The frontend Nginx only binds to `127.0.0.1:8081` — put a reverse proxy (Caddy/Nginx) in front for HTTPS
- PostgreSQL data is persisted in the `postgres_data` Docker volume — back it up regularly
- Uploads live in `uploads_data` volume — includes extracted question images
- Set `APP_URL` to your public domain so verification/reset email links work

View file

@ -13,6 +13,17 @@ class Settings(BaseSettings):
LITELLM_MODEL: str = "gpt-4o-mini"
LITELLM_API_KEY: str = ""
LITELLM_API_BASE: str = ""
LITELLM_EMBEDDING_MODEL: str = ""
OPENAI_API_KEY: str = ""
ELEVENLABS_API_KEY: str = ""
GOOGLE_TTS_API_KEY: str = ""
AWS_ACCESS_KEY_ID: str = ""
AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1"
AWS_BEDROCK_REGION: str = "us-east-1"
EMBEDDING_DIMENSIONS: int = 1024
APP_URL: str = "https://quiz.danvics.com"
CHROMA_PERSIST_DIR: str = "./chroma_data"

View file

@ -1,20 +1,9 @@
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View file

@ -15,6 +15,8 @@ from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin():
"""Create default admin user if none exists."""
from app.models.user import User
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
db = SessionLocal()
try:
admin_exists = db.query(User).filter(User.role == "admin").first()
@ -26,7 +28,29 @@ def seed_admin():
role="admin",
)
db.add(admin_user)
db.flush()
# Auto-verify seeded admin
from datetime import datetime
db.add(EmailVerification(
user_id=admin_user.id,
token="seeded",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
else:
# Ensure existing admin has a verified email record
from app.models.email_verification import EmailVerification
from datetime import datetime
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
if not existing_v:
db.add(EmailVerification(
user_id=admin_exists.id,
token=f"legacy_{admin_exists.id}",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
finally:
db.close()
@ -38,57 +62,123 @@ def seed_default_models():
try:
if db.query(AIModelConfig).count() == 0:
defaults = [
AIModelConfig(
name="GPT-4o Mini (Extraction)",
model_id="gpt-4o-mini",
task="extraction",
is_active=True,
is_default=True,
),
AIModelConfig(
name="GPT-4o (Extraction)",
model_id="gpt-4o",
task="extraction",
is_active=True,
is_default=False,
),
AIModelConfig(
name="Claude Sonnet 4.6 (Extraction)",
model_id="anthropic/claude-sonnet-4-6-20250514",
task="extraction",
is_active=True,
is_default=False,
),
AIModelConfig(
name="Google Vertex TTS",
model_id="vertex_ai/google/cloud-tts",
task="tts",
is_active=True,
is_default=True,
),
AIModelConfig(
name="OpenAI TTS",
model_id="openai/tts-1",
task="tts",
is_active=True,
is_default=False,
),
AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True),
AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False),
AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
]
db.add_all(defaults)
db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent)
tts_voices = [
# OpenAI (work with OPENAI_API_KEY)
("OpenAI Alloy", "tts-1:alloy", True),
("OpenAI Nova", "tts-1:nova", False),
("OpenAI Echo", "tts-1:echo", False),
("OpenAI Shimmer", "tts-1:shimmer", False),
("OpenAI Onyx", "tts-1:onyx", False),
("OpenAI Fable", "tts-1:fable", False),
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
("OpenAI Nova HD", "tts-1-hd:nova", False),
# ElevenLabs (work with ELEVENLABS_API_KEY)
("ElevenLabs Adam", "elevenlabs/adam", False),
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
("AWS Polly Amy (en-GB)", "polly/Amy", False),
("AWS Polly Brian (en-GB)", "polly/Brian", False),
]
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
for old_id in ("tts-1", "tts-1-hd"):
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
if old:
old.is_active = False
old.is_default = False
has_default_tts = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
).first() is not None
for name, model_id, _ in tts_voices:
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
if not exists:
is_def = not has_default_tts
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
if is_def:
has_default_tts = True
db.commit()
finally:
db.close()
def setup_pgvector():
"""Enable pgvector extension and add embedding column if missing."""
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS embedding vector(1024)
"""))
# HNSW index for fast cosine similarity — created only if not exists
conn.execute(text("""
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
ON questions USING hnsw (embedding vector_cosine_ops)
"""))
conn.commit()
def backfill_embeddings():
"""Generate embeddings for questions that don't have one yet (background, best-effort)."""
import threading
from app.models.question import Question
from app.services import embedding_service
def _run():
db = SessionLocal()
try:
missing = db.query(Question).filter(Question.embedding.is_(None)).all()
if not missing:
return
import logging
log = logging.getLogger(__name__)
log.info(f"Backfilling embeddings for {len(missing)} questions...")
ok = 0
for q in missing:
try:
if embedding_service.embed_question(q):
ok += 1
except Exception:
pass
db.commit()
log.info(f"Backfill complete: {ok}/{len(missing)} embedded")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Embedding backfill failed: {e}")
finally:
db.close()
threading.Thread(target=_run, daemon=True).start()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
Base.metadata.create_all(bind=engine)
setup_pgvector()
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
seed_admin()
seed_default_models()
backfill_embeddings()
start_scheduler()
yield
# Shutdown
@ -96,15 +186,15 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="PDF Quiz Generator",
description="Convert PDF files into interactive quizzes with AI",
title="PedQuiz",
description="Pediatric Knowledge Quiz Platform",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"],
allow_origins=["https://quiz.danvics.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View file

@ -10,8 +10,8 @@ class QuizAttempt(Base):
__tablename__ = "quiz_attempts"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
score = Column(Integer, default=0)
total_questions = Column(Integer, default=0)
started_at = Column(DateTime, default=datetime.utcnow)
@ -26,8 +26,8 @@ class AttemptAnswer(Base):
__tablename__ = "attempt_answers"
id = Column(Integer, primary_key=True, index=True)
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
user_answer = Column(String, nullable=False)
is_correct = Column(Boolean, default=False)

View file

@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from app.database import Base
class EmailVerification(Base):
__tablename__ = "email_verifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
token = Column(String, unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
verified_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from app.database import Base
class PasswordReset(Base):
__tablename__ = "password_resets"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token = Column(String, unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -10,7 +10,7 @@ class PDFDocument(Base):
__tablename__ = "pdf_documents"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
filename = Column(String, nullable=False)
original_filename = Column(String, nullable=False)
total_pages = Column(Integer, nullable=True)

View file

@ -1,6 +1,8 @@
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship
from app.config import settings
from app.database import Base
@ -8,13 +10,14 @@ class Question(Base):
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
question_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
options = Column(JSON, nullable=True) # list of strings for mcq
correct_answer = Column(String, nullable=False)
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True) # path to extracted image, if any
image_path = Column(String, nullable=True)
embedding = Column(Vector(1024), nullable=True) # semantic search vector
quiz = relationship("Quiz", back_populates="questions")

View file

@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.database import Base
@ -16,9 +16,11 @@ class Quiz(Base):
questions_count = Column(Integer, default=0)
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
mode = Column(String, default="timed") # timed, learning
skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts
created_at = Column(DateTime, default=datetime.utcnow)
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")

View file

@ -10,8 +10,8 @@ class ReminderSchedule(Base):
__tablename__ = "reminder_schedules"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
next_reminder_at = Column(DateTime, nullable=False)
interval_days = Column(Integer, default=1)
performance_score = Column(Float, default=0.0)

View file

@ -8,7 +8,7 @@ class Section(Base):
__tablename__ = "sections"
id = Column(Integer, primary_key=True, index=True)
document_id = Column(Integer, ForeignKey("pdf_documents.id"), nullable=False)
document_id = Column(Integer, ForeignKey("pdf_documents.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
start_page = Column(Integer, nullable=False)
end_page = Column(Integer, nullable=False)

View file

@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
import httpx
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.schemas.auth import UserResponse, UserUpdateRole
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin
from app.utils.auth import require_admin, get_current_user, get_password_hash
router = APIRouter()
@ -43,8 +45,88 @@ def update_user_role(
return user
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Admin creates a user directly — email is auto-verified."""
from app.models.email_verification import EmailVerification
from datetime import datetime
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="user",
)
db.add(user)
db.flush()
db.add(EmailVerification(
user_id=user.id,
token=f"admin_created_{user.id}",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
db.refresh(user)
return user
# --- AI Model Configuration ---
@router.get("/models/available")
def list_available_models(
task: str = Query("extraction"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Returns active models for a given task — for users to choose when taking/creating a quiz."""
models = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
# Always include env default as fallback if nothing configured
if not result:
result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True})
return result
@router.get("/litellm/models")
def search_litellm_models(
api_key: str = Query(None),
api_base: str = Query(None),
admin: User = Depends(require_admin),
):
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = api_key or settings.LITELLM_API_KEY
if base:
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
models = sorted([m["id"] for m in data.get("data", [])])
return {"models": models, "source": base}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
# Fall back to LiteLLM's built-in model list
try:
import litellm
models = sorted(litellm.utils.get_valid_models())
return {"models": models, "source": "litellm-builtin"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get LiteLLM models: {e}")
@router.get("/models", response_model=list[AIModelConfigResponse])
def list_models(
db: Session = Depends(get_db),
@ -62,7 +144,6 @@ def create_model(
if data.task not in ("extraction", "tts", "general"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
# If setting as default, unset other defaults for same task
if data.is_default:
db.query(AIModelConfig).filter(
AIModelConfig.task == data.task,
@ -89,7 +170,6 @@ def update_model(
update_data = data.model_dump(exclude_unset=True)
# If setting as default, unset other defaults for same task
task = update_data.get("task", model.task)
if update_data.get("is_default"):
db.query(AIModelConfig).filter(

View file

@ -29,7 +29,7 @@ def start_attempt(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first()
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
@ -74,33 +74,37 @@ def submit_attempt(
}
score = 0
answer_details = []
submitted = {ans.question_id: ans.user_answer for ans in submission.answers}
# Save submitted answers
for ans in submission.answers:
question = questions.get(ans.question_id)
if not question:
continue
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
attempt_answer = AttemptAnswer(
db.add(AttemptAnswer(
attempt_id=attempt_id,
question_id=ans.question_id,
user_answer=ans.user_answer,
is_correct=is_correct,
)
db.add(attempt_answer)
))
# Build full review — include ALL questions, unanswered marked as incorrect
answer_details = []
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all():
user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
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=question.explanation,
explanation=q.explanation,
))
attempt.score = score
@ -153,6 +157,39 @@ def list_attempts(
]
@router.get("/history")
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()
# Group by quiz
from collections import defaultdict
by_quiz: dict = defaultdict(list)
quiz_titles: dict = {}
for a in completed:
pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1)
by_quiz[a.quiz_id].append({
"date": a.completed_at.isoformat(),
"percentage": pct,
"score": a.score,
"total": a.total_questions,
})
if a.quiz_id not in quiz_titles:
quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first()
quiz_titles[a.quiz_id] = quiz.title if quiz else f"Quiz {a.quiz_id}"
return [
{"quiz_id": qid, "title": quiz_titles[qid], "attempts": attempts}
for qid, attempts in by_quiz.items()
]
@router.get("/stats/dashboard", response_model=DashboardStats)
def get_dashboard_stats(
db: Session = Depends(get_db),
@ -210,17 +247,20 @@ def get_attempt(
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
# Show ALL questions in review, not just answered ones
submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = []
for ans in attempt.answers:
question = ans.question
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all():
ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail(
question_id=question.id,
question_text=question.question_text,
question_type=question.question_type,
user_answer=ans.user_answer,
correct_answer=question.correct_answer,
is_correct=ans.is_correct,
explanation=question.explanation,
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

View file

@ -1,49 +1,224 @@
from fastapi import APIRouter, Depends, HTTPException, status
import secrets
from collections import defaultdict
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest
from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user
from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
from app.schemas.auth import (
UserCreate, UserResponse, Token, LoginRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest,
)
from app.services import email_service
from app.utils.auth import (
get_password_hash, verify_password, create_access_token, get_current_user,
)
router = APIRouter()
# Simple in-memory login rate limiter: max 10 attempts per IP per 15 min
_login_attempts: dict = defaultdict(list)
@router.post("/register", response_model=Token)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
def _check_login_rate_limit(client_ip: str):
now = datetime.utcnow()
window = now - timedelta(minutes=15)
attempts = [t for t in _login_attempts[client_ip] if t > window]
_login_attempts[client_ip] = attempts
if len(attempts) >= 10:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
_login_attempts[client_ip].append(now)
# Rate limit: max 3 reset requests per email per hour
RESET_LIMIT = 3
RESET_WINDOW_HOURS = 1
def _check_reset_rate_limit(db: Session, email: str):
user = db.query(User).filter(User.email == email).first()
if not user:
return # silently ignore unknown emails
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
count = db.query(PasswordReset).filter(
PasswordReset.user_id == user.id,
PasswordReset.created_at >= window_start,
).count()
if count >= RESET_LIMIT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many reset requests. Please wait before trying again.",
)
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
is_first_user = db.query(User).count() == 0
user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="admin" if is_first_user else "user",
)
db.add(user)
db.flush()
# Create email verification record
token = secrets.token_urlsafe(32)
verification = EmailVerification(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=24),
verified_at=datetime.utcnow() if is_first_user else None,
)
db.add(verification)
db.commit()
db.refresh(user)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
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"}
# 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."}
@router.post("/login", response_model=Token)
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
if request:
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)
user = db.query(User).filter(User.email == login_data.email).first()
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Check email verification — skip for users without any verification record (legacy/seeded)
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
status_code=403,
detail="Email not verified. Please check your inbox and verify your email before logging in.",
)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
@router.get("/verify-email")
def verify_email(token: str, db: Session = Depends(get_db)):
record = db.query(EmailVerification).filter(EmailVerification.token == token).first()
if not record:
raise HTTPException(status_code=400, detail="Invalid verification token")
if record.verified_at is not None:
return {"message": "Email already verified. You can log in."}
if datetime.utcnow() > record.expires_at:
raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.")
record.verified_at = datetime.utcnow()
db.commit()
return {"message": "Email verified successfully! You can now log in."}
@router.post("/resend-verification")
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == data.email).first()
if not user:
return {"message": "If that email exists, a verification link has been sent."}
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if record and record.verified_at is not None:
return {"message": "Email already verified."}
token = secrets.token_urlsafe(32)
if record:
record.token = token
record.expires_at = datetime.utcnow() + timedelta(hours=24)
else:
db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
db.commit()
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"message": "If that email exists, a verification link has been sent."}
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_check_reset_rate_limit(db, data.email)
user = db.query(User).filter(User.email == data.email).first()
# Always return same message to avoid email enumeration
if not user:
return {"message": "If that email is registered, a reset link has been sent."}
token = secrets.token_urlsafe(32)
reset = PasswordReset(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1),
)
db.add(reset)
db.commit()
background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
return {"message": "If that email is registered, a reset link has been sent."}
@router.post("/reset-password")
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
record = db.query(PasswordReset).filter(
PasswordReset.token == data.token,
PasswordReset.used == False,
).first()
if not record:
raise HTTPException(status_code=400, detail="Invalid or already used reset token")
if datetime.utcnow() > record.expires_at:
raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
user = db.query(User).filter(User.id == record.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = get_password_hash(data.new_password)
record.used = True
db.commit()
return {"message": "Password reset successfully. You can now log in."}
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=UserResponse)
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if data.new_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
if not verify_password(data.current_password, current_user.hashed_password):
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")
current_user.hashed_password = get_password_hash(data.new_password)
if data.name:
current_user.name = data.name
db.commit()
db.refresh(current_user)
return current_user

View file

@ -28,7 +28,7 @@ def upload_document(
# Save file to disk streaming (handles large files)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{file.filename}"
safe_name = f"{uuid.uuid4()}_{os.path.basename(file.filename)}"
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
with open(file_path, "wb") as buffer:
@ -140,6 +140,10 @@ def create_section(
raise HTTPException(status_code=404, detail="Document not found")
if doc.status != "ready":
raise HTTPException(status_code=400, detail="Document is not ready yet")
if section_data.start_page < 1 or section_data.end_page < 1:
raise HTTPException(status_code=400, detail="Page numbers must be positive")
if section_data.start_page >= section_data.end_page:
raise HTTPException(status_code=400, detail="start_page must be less than end_page")
if doc.total_pages and section_data.end_page > doc.total_pages:
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")

View file

@ -1,8 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException
import random
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question as QuestionModel
from app.models.section import Section
from app.models.attempt import QuizAttempt
from app.models.user import User
@ -37,6 +40,7 @@ def create_quiz(
title=quiz_data.title,
mode=quiz_data.mode,
time_limit_minutes=quiz_data.time_limit_minutes,
model_id=quiz_data.model_id,
)
return quiz
except ValueError as e:
@ -45,6 +49,119 @@ def create_quiz(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/search")
def search_quizzes(
q: str = Query(..., min_length=2, max_length=200),
mode: str = Query("all"), # "title" | "questions" | "all"
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Hybrid semantic + keyword search across quiz titles and questions."""
from sqlalchemy import text as sa_text
from app.services import embedding_service
phrase = q.strip()
if not phrase:
return []
results = {} # quiz_id -> result dict
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:
return False
results[quiz_id] = {
"quiz_id": quiz.id,
"quiz_title": quiz.title,
"questions_count": quiz.questions_count,
"mode": quiz.mode,
"time_limit_minutes": quiz.time_limit_minutes,
"match_type": match_type,
"matching_questions": [],
}
elif results[quiz_id]["match_type"] != match_type and match_type != "title":
results[quiz_id]["match_type"] = "both"
return True
# ── Title search ─────────────────────────────────────────────
if mode in ("title", "all"):
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all():
_ensure_quiz(quiz.id, "title")
# ── Semantic (vector) search ──────────────────────────────────
seen_question_ids = set()
if mode in ("questions", "all"):
query_emb = embedding_service.generate_embedding(phrase)
if query_emb:
# Use f-string for the vector literal — safe because it's a list of floats
emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]"
rows = db.execute(sa_text(f"""
SELECT q.id, q.quiz_id, q.question_text, q.options,
1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity
FROM questions q
WHERE q.embedding IS NOT NULL
ORDER BY q.embedding <=> '{emb_literal}'::vector
LIMIT 40
""")).fetchall()
for row in rows:
similarity = float(row.similarity)
if similarity < 0.30:
continue
if _ensure_quiz(row.quiz_id, "questions"):
seen_question_ids.add(row.id)
results[row.quiz_id]["matching_questions"].append({
"id": row.id,
"question_text": row.question_text,
"options": row.options,
"similarity": round(similarity, 3),
"match_source": "semantic",
})
# ── Keyword (ILIKE) search ────────────────────────────────────
if mode in ("questions", "all"):
q_filter = or_(
QuestionModel.question_text.ilike(f"%{phrase}%"),
cast(QuestionModel.options, String).ilike(f"%{phrase}%"),
)
keyword_rows = (
db.query(QuestionModel)
.filter(q_filter)
.order_by(QuestionModel.quiz_id, QuestionModel.id)
.limit(200)
.all()
)
for question in keyword_rows:
if _ensure_quiz(question.quiz_id, "questions"):
if question.id not in seen_question_ids:
seen_question_ids.add(question.id)
results[question.quiz_id]["matching_questions"].append({
"id": question.id,
"question_text": question.question_text,
"options": question.options,
"similarity": None,
"match_source": "keyword",
})
# Sort each quiz's questions: semantic first (by similarity desc), then keyword
for r in results.values():
r["matching_questions"].sort(
key=lambda x: (0 if x.get("match_source") == "semantic" else 1,
-(x.get("similarity") or 0))
)
# Sort results: title matches first, then by number of semantic hits desc
sorted_results = sorted(
results.values(),
key=lambda r: (
0 if r["match_type"] == "title" else 1,
-sum(1 for q in r["matching_questions"] if q.get("match_source") == "semantic"),
),
)
return sorted_results
@router.get("/", response_model=list[QuizResponse])
def list_quizzes(
db: Session = Depends(get_db),
@ -61,19 +178,65 @@ def list_quizzes(
@router.get("/{quiz_id}")
def get_quiz(
quiz_id: int,
study: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz for taking. Learning mode includes answers."""
"""Get quiz for taking. study=true forces answers/explanations to be included."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if quiz.mode == "learning":
if study or quiz.mode == "learning":
return QuizLearningDetail.model_validate(quiz)
return QuizDetail.model_validate(quiz)
@router.post("/{quiz_id}/shuffle", response_model=QuizDetail)
def shuffle_quiz(
quiz_id: int,
shuffle_options: bool = Query(True, description="Also shuffle answer options within each question"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return quiz with question order and optionally option order shuffled. Does not modify DB."""
from app.models.question import Question as QuestionModel
from app.schemas.quiz import QuestionResponse
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).all()
shuffled_questions = questions.copy()
random.shuffle(shuffled_questions)
result = []
for q in shuffled_questions:
options = list(q.options) if q.options else None
if shuffle_options and options:
random.shuffle(options)
result.append(QuestionResponse(
id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=options,
image_path=q.image_path,
))
return {
"id": quiz.id,
"section_id": quiz.section_id,
"user_id": quiz.user_id,
"title": quiz.title,
"questions_count": quiz.questions_count,
"mode": quiz.mode,
"time_limit_minutes": quiz.time_limit_minutes,
"created_at": quiz.created_at,
"questions": result,
}
@router.get("/{quiz_id}/review", response_model=QuizReview)
def review_quiz(
quiz_id: int,
@ -96,6 +259,96 @@ def review_quiz(
return quiz
@router.get("/{quiz_id}/questions")
def get_quiz_questions_for_edit(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Get all questions with answers for editing — moderator/admin only."""
from app.models.question import Question as QuestionModel
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).order_by(QuestionModel.id).all()
return [
{
"id": q.id,
"question_text": q.question_text,
"question_type": q.question_type,
"options": q.options,
"correct_answer": q.correct_answer,
"explanation": q.explanation,
}
for q in questions
]
@router.patch("/{quiz_id}/questions/{question_id}")
def update_question(
quiz_id: int,
question_id: int,
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Update a quiz question — moderator/admin only."""
from app.models.question import Question as QuestionModel
question = db.query(QuestionModel).filter(
QuestionModel.id == question_id,
QuestionModel.quiz_id == quiz_id,
).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type"}
for key, value in data.items():
if key in allowed:
setattr(question, key, value)
# Validate correct_answer is one of the options
if question.options and question.correct_answer not in question.options:
raise HTTPException(
status_code=400,
detail=f"correct_answer must match one of the options exactly. Options: {question.options}"
)
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,
}
@router.delete("/{quiz_id}/questions/{question_id}", status_code=204)
def delete_question(
quiz_id: int,
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a single question from a quiz — moderator/admin only."""
from app.models.question import Question as QuestionModel
question = db.query(QuestionModel).filter(
QuestionModel.id == question_id,
QuestionModel.quiz_id == quiz_id,
).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if quiz and quiz.questions_count > 0:
quiz.questions_count -= 1
db.delete(question)
db.commit()
@router.delete("/{quiz_id}", status_code=204)
def delete_quiz(
quiz_id: int,

View file

@ -3,8 +3,10 @@ from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service
from app.utils.auth import get_current_user
@ -13,6 +15,22 @@ router = APIRouter()
class TTSRequest(BaseModel):
text: str
voice: str | None = None # model_id override
@router.get("/voices")
def get_voices(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return available TTS voices from DB + env fallback."""
db_models = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
voices = [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
return voices
@router.post("/speak")
@ -21,15 +39,24 @@ def text_to_speech(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Convert text to speech using configured TTS model."""
"""Convert text to speech using configured or user-selected TTS model."""
if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")
# Limit text length
text = request.text[:2000]
# Get TTS model config
model_id, api_key = ai_service.get_model_for_task(db, "tts")
if request.voice:
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None
else:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
model_id = config.model_id if config else "tts-1:alloy"
api_key = config.api_key if (config and config.api_key) else None
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None:

View file

@ -14,7 +14,7 @@ class AIModelConfigCreate(BaseModel):
class AIModelConfigResponse(BaseModel):
model_config = {"protected_namespaces": ()}
model_config = {"protected_namespaces": (), "from_attributes": True}
id: int
name: str
model_id: str
@ -23,9 +23,6 @@ class AIModelConfigResponse(BaseModel):
is_default: bool
created_at: datetime
class Config:
from_attributes = True
class AIModelConfigUpdate(BaseModel):
model_config = {"protected_namespaces": ()}

View file

@ -16,6 +16,7 @@ class AnswerDetail(BaseModel):
question_id: int
question_text: str
question_type: str
options: list[str] | None = None
user_answer: str
correct_answer: str
is_correct: bool

View file

@ -31,4 +31,19 @@ class LoginRequest(BaseModel):
class UserUpdateRole(BaseModel):
role: str # admin, moderator, user
role: str
class UserUpdateMe(BaseModel):
name: str | None = None
current_password: str | None = None
new_password: str | None = None
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str

View file

@ -8,6 +8,7 @@ class QuizCreate(BaseModel):
title: str
mode: str = "timed" # timed, learning
time_limit_minutes: int | None = None
model_id: str | None = None # override extraction model
class QuestionResponse(BaseModel):
@ -35,6 +36,7 @@ class QuizResponse(BaseModel):
questions_count: int
mode: str
time_limit_minutes: int | None
skipped_questions: str | None = None
created_at: datetime
class Config:

View file

@ -7,28 +7,46 @@ from app.config import settings
logger = logging.getLogger(__name__)
EXTRACTION_PROMPT = """You are a quiz question extractor. The following content is from a PDF that contains quiz/exam questions with answers and explanations.
Your job is to EXTRACT (not generate) all the questions, their options, correct answers, and explanations exactly as they appear in the content.
def _proxy_model(model_id: str) -> str:
"""Prefix model with openai/ if using a LiteLLM proxy and no provider is specified."""
if settings.LITELLM_API_BASE and "/" not in model_id:
return f"openai/{model_id}"
return model_id
Return a JSON object with a "questions" key containing an array where each object has:
- "question_text": the full question text as it appears
- "question_type": "mcq" if it has multiple choice options, "true_false" if True/False, "fill_blank" if fill-in-the-blank
- "options": array of option strings exactly as written (for mcq), ["True", "False"] (for true_false), or null (for fill_blank)
- "correct_answer": the correct answer exactly as marked in the source
- "explanation": the explanation text if provided, or "" if none
- "page_reference": {page_ref}
EXTRACTION_PROMPT = """You are extracting questions from a PREP (Pediatric Review and Education Program) exam PDF.
Important:
- Extract ALL questions found in the content do not skip any
- Preserve the original wording exactly
- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be
- If no clear correct answer is marked, use the best answer based on the explanation
These PDFs follow a strict format:
1. A numbered question with a clinical vignette (patient scenario)
2. Five answer options labeled A, B, C, D, E
3. A line "Correct Answer: X" where X is the letter of the correct option
4. An explanation paragraph
5. A "Critique:" section with detailed reasoning
6. A "Content Specifications:" section listing the learning objectives
Your task: extract every question from the content below and return ONLY a JSON object in this exact format:
{{"questions": [
{{
"question_text": "<full question stem including any patient vignette>",
"question_type": "mcq",
"options": ["<option A text>", "<option B text>", "<option C text>", "<option D text>", "<option E text>"],
"correct_answer": "<full text of the correct option, not just the letter>",
"explanation": "<explanation paragraph>\\n\\nCritique: <critique section verbatim>\\n\\nContent Specifications: <content spec section verbatim>",
"page_reference": {page_ref}
}}
]}}
Rules:
- "Correct Answer: C" means option C is correct store the full text of option C as correct_answer
- If no "Correct Answer:" line appears after a question, set correct_answer to null
- Preserve ALL text exactly as written do not summarize or paraphrase
- Include the Critique and Content Specifications verbatim they are critical
- Extract ALL questions in the content, even if partially cut off
- Return ONLY the JSON no markdown, no explanation, no preamble
Content from page(s) {page_info}:
{content}
Return ONLY valid JSON. No markdown formatting."""
{content}"""
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
@ -70,7 +88,7 @@ def extract_questions(
page_ref=page_ref if page_ref else "null",
)
use_model = model_id or settings.LITELLM_MODEL
use_model = _proxy_model(model_id or settings.LITELLM_MODEL)
use_key = api_key or settings.LITELLM_API_KEY
last_error = None
@ -83,16 +101,14 @@ def extract_questions(
}
if use_key:
kwargs["api_key"] = use_key
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
# Try JSON mode if supported
try:
kwargs["response_format"] = {"type": "json_object"}
response = litellm.completion(**kwargs)
except Exception:
kwargs.pop("response_format", None)
response = litellm.completion(**kwargs)
# Don't force JSON mode — let the model respond naturally and we parse it
response = litellm.completion(**kwargs)
response_text = response.choices[0].message.content
logger.info(f"Model raw response (first 500 chars): {response_text[:500]!r}")
# Try to parse JSON, handle markdown code blocks
text = response_text.strip()
@ -103,13 +119,33 @@ def extract_questions(
text = text.strip()
data = json.loads(text)
questions = data.get("questions", data) if isinstance(data, dict) else data
if not isinstance(questions, list):
# Handle all common response shapes
if isinstance(data, list):
questions = data
elif isinstance(data, dict):
# Try common keys
for key in ("questions", "items", "results", "data"):
if isinstance(data.get(key), list):
questions = data[key]
break
else:
# Maybe the dict itself is a single question
if "question_text" in data:
questions = [data]
else:
raise ValueError(f"Unexpected response shape: {list(data.keys())}")
else:
raise ValueError("Response is not a list of questions")
validated = []
skipped = []
for q in questions:
if not all(k in q for k in ("question_text", "correct_answer")):
if "question_text" not in q:
continue
correct = q.get("correct_answer")
if not correct:
skipped.append(q.get("question_text", "")[:120])
continue
qtype = q.get("question_type", "mcq")
if qtype not in ("mcq", "true_false", "fill_blank"):
@ -118,11 +154,16 @@ def extract_questions(
"question_text": q["question_text"],
"question_type": qtype,
"options": q.get("options"),
"correct_answer": q["correct_answer"],
"correct_answer": correct,
"explanation": q.get("explanation", ""),
"page_reference": q.get("page_reference"),
"skipped": [],
})
# Attach skipped list to first question so caller can surface it
if validated and skipped:
validated[0]["skipped"] = skipped
if validated:
return validated
@ -130,7 +171,7 @@ def extract_questions(
except Exception as e:
last_error = e
logger.warning(f"Extraction attempt {attempt + 1} failed: {e}")
logger.warning(f"Extraction attempt {attempt + 1} failed: {e!r}")
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
@ -140,23 +181,120 @@ def generate_tts_audio(
model_id: str | None = None,
api_key: str | None = None,
) -> bytes | None:
"""Generate TTS audio using LiteLLM (supports Google Vertex, OpenAI, etc.)."""
use_model = model_id or "vertex_ai/google/cloud-tts"
use_key = api_key or settings.LITELLM_API_KEY
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
model_id conventions:
tts-1:alloy OpenAI TTS (voice after colon)
tts-1-hd:nova OpenAI TTS HD
elevenlabs/<voice> ElevenLabs
google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D)
polly/<VoiceId> AWS Polly Neural (e.g. polly/Joanna)
"""
import httpx, base64
use_model = model_id or "tts-1:alloy"
# ── ElevenLabs ─────────────────────────────────────────────
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
voice = use_model.split("/", 1)[1]
key = api_key or settings.ELEVENLABS_API_KEY
if not key:
logger.error("ElevenLabs API key not configured")
return None
try:
resp = httpx.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice}",
headers={"xi-api-key": key, "Content-Type": "application/json"},
json={"text": text, "model_id": "eleven_turbo_v2_5"},
timeout=30,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"ElevenLabs TTS failed: {e}")
return None
# ── Google Cloud TTS ────────────────────────────────────────
if use_model.startswith("google/"):
voice_name = use_model[len("google/"):]
key = api_key or settings.GOOGLE_TTS_API_KEY
if not key:
logger.error("Google TTS API key not configured (GOOGLE_TTS_API_KEY)")
return None
# Parse language code from voice name (e.g. "en-US-Wavenet-D" → "en-US")
parts = voice_name.split("-")
lang_code = f"{parts[0]}-{parts[1]}" if len(parts) >= 2 else "en-US"
try:
resp = httpx.post(
f"https://texttospeech.googleapis.com/v1/text:synthesize?key={key}",
json={
"input": {"text": text},
"voice": {"languageCode": lang_code, "name": voice_name},
"audioConfig": {"audioEncoding": "MP3"},
},
timeout=30,
)
resp.raise_for_status()
return base64.b64decode(resp.json()["audioContent"])
except Exception as e:
logger.error(f"Google TTS failed: {e}")
return None
# ── AWS Polly ───────────────────────────────────────────────
if use_model.startswith("polly/"):
voice_id = use_model[len("polly/"):]
access_key = api_key or settings.AWS_ACCESS_KEY_ID
secret_key = settings.AWS_SECRET_ACCESS_KEY
region = settings.AWS_REGION or "us-east-1"
if not access_key or not secret_key:
logger.error("AWS credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
return None
try:
import boto3
polly = boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
)
response = polly.synthesize_speech(
Text=text,
OutputFormat="mp3",
VoiceId=voice_id,
Engine="neural",
)
return response["AudioStream"].read()
except Exception as e:
logger.error(f"AWS Polly TTS failed: {e}")
return None
# ── OpenAI (default) ────────────────────────────────────────
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
clean_model = use_model.replace("openai/", "")
oai_voice = "alloy"
if ":" in clean_model:
clean_model, oai_voice = clean_model.split(":", 1)
# Per-model key > OPENAI_API_KEY (direct) > LITELLM_API_KEY (proxy)
if api_key:
key = api_key
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/")
elif settings.OPENAI_API_KEY:
key = settings.OPENAI_API_KEY
base = "https://api.openai.com"
else:
key = settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/")
try:
# LiteLLM speech endpoint
response = litellm.speech(
model=use_model,
input=text,
api_key=use_key if use_key else None,
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": clean_model, "input": text, "voice": oai_voice},
timeout=60,
)
# Response is audio bytes
if hasattr(response, "content"):
return response.content
if hasattr(response, "read"):
return response.read()
return bytes(response)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"TTS generation failed: {e}")
logger.error(f"OpenAI TTS failed: {e}")
return None

View file

@ -1,4 +1,6 @@
import logging
import re
from datetime import datetime
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
@ -12,6 +14,7 @@ def get_mail_config() -> ConnectionConfig:
MAIL_USERNAME=settings.MAIL_USERNAME,
MAIL_PASSWORD=settings.MAIL_PASSWORD,
MAIL_FROM=settings.MAIL_FROM,
MAIL_FROM_NAME="PedQuiz",
MAIL_PORT=settings.MAIL_PORT,
MAIL_SERVER=settings.MAIL_SERVER,
MAIL_STARTTLS=settings.MAIL_STARTTLS,
@ -20,45 +23,152 @@ def get_mail_config() -> ConnectionConfig:
)
async def send_reminder_email(
email: str,
user_name: str,
quiz_title: str,
score: float,
next_date: str,
):
"""Send a spaced-repetition reminder email."""
html = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">Quiz Reminder</h2>
<p>Hi {user_name},</p>
<p>It's time to review <strong>{quiz_title}</strong>!</p>
<p>Your last score was <strong>{score:.0f}%</strong>.
{'Great progress! Keep it up.' if score >= 70 else 'Practice makes perfect — give it another try!'}</p>
<div style="background: #f3f4f6; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0;"><strong>Tip:</strong> Spaced repetition helps you remember more over time.
Each review strengthens your memory!</p>
</div>
<p>Log in to take the quiz again.</p>
<p style="color: #6b7280; font-size: 12px;">
You're receiving this because you have active quiz reminders.
</p>
</body>
</html>
def _render(md: str) -> str:
"""
Minimal markdown inline HTML for email.
Resend-style: white background, dark text, clean sans-serif, one column.
"""
lines, out = md.strip().split("\n"), []
for line in lines:
s = line.strip()
# Bold inline
s_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
message = MessageSchema(
subject=f"Quiz Reminder: {quiz_title}",
recipients=[email],
body=html,
subtype=MessageType.html,
)
if s.startswith("# "):
out.append(f'<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#09090b;letter-spacing:-0.4px;line-height:1.2;">{s[2:]}</h1>')
elif s.startswith("## "):
out.append(f'<h2 style="margin:24px 0 8px;font-size:15px;font-weight:600;color:#18181b;text-transform:uppercase;letter-spacing:0.05em;">{s[3:]}</h2>')
elif s.startswith("> "):
out.append(f'<blockquote style="margin:16px 0;padding:12px 16px;background:#f4f4f5;border-left:3px solid #d4d4d8;border-radius:0 6px 6px 0;font-size:13px;color:#71717a;line-height:1.6;">{s_html[2:]}</blockquote>')
elif s.startswith("---"):
out.append('<hr style="border:none;border-top:1px solid #f4f4f5;margin:28px 0;"/>')
elif s.startswith("[button:"):
m = re.match(r'\[button:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'<p style="margin:28px 0;"><a href="{url}" style="display:inline-block;background:#09090b;color:#fafafa;font-size:14px;font-weight:500;padding:10px 24px;border-radius:6px;text-decoration:none;letter-spacing:0.01em;">{label} →</a></p>')
elif s.startswith("[link:"):
m = re.match(r'\[link:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'<p style="margin:4px 0;font-size:12px;color:#a1a1aa;">Or copy: <a href="{url}" style="color:#71717a;text-decoration:underline;word-break:break-all;">{url}</a></p>')
elif s == "":
out.append('<div style="height:10px;"></div>')
else:
out.append(f'<p style="margin:0 0 12px;font-size:14px;color:#3f3f46;line-height:1.7;">{s_html}</p>')
return "\n".join(out)
def _wrap(subject: str, body_md: str) -> str:
year = datetime.utcnow().year
body_html = _render(body_md)
return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>{subject}</title></head>
<body style="margin:0;padding:0;background:#fafafa;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:48px 20px 32px;">
<table width="540" cellpadding="0" cellspacing="0" style="max-width:540px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:36px;">
<span style="font-size:15px;font-weight:600;color:#09090b;letter-spacing:-0.2px;">🩺 PedQuiz</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border:1px solid #e4e4e7;border-radius:8px;padding:40px;">
{body_html}
</td></tr>
<!-- Footer -->
<tr><td style="padding:24px 0 0;">
<p style="margin:0;font-size:12px;color:#a1a1aa;line-height:1.6;">
PedQuiz · Pediatric Knowledge Platform<br/>
<a href="{settings.APP_URL}" style="color:#a1a1aa;text-decoration:underline;">{settings.APP_URL}</a>
</p>
<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">
Didn't expect this email? You can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
async def _send(to_email: str, subject: str, html: str):
if not settings.MAIL_USERNAME or not settings.MAIL_FROM:
logger.info(f"[EMAIL not configured] To:{to_email} Subject:{subject}")
return
try:
conf = get_mail_config()
fm = FastMail(conf)
await fm.send_message(message)
logger.info(f"Reminder sent to {email} for quiz '{quiz_title}'")
fm = FastMail(get_mail_config())
await fm.send_message(MessageSchema(subject=subject, recipients=[to_email], body=html, subtype=MessageType.html))
logger.info(f"Email sent → {to_email}: {subject}")
except Exception as e:
logger.error(f"Failed to send reminder to {email}: {e}")
logger.error(f"Email failed → {to_email}: {e}")
async def send_verification_email(to_email: str, name: str, token: str):
url = f"{settings.APP_URL}/verify-email?token={token}"
subject = "Verify your PedQuiz email"
md = f"""# Verify your email
Hi **{name}**,
Welcome to PedQuiz. Click below to verify your email address and activate your account.
[button:Verify Email Address]({url})
> This link expires in **24 hours**.
---
[link:Or copy this link]({url})
"""
await _send(to_email, subject, _wrap(subject, md))
async def send_password_reset_email(to_email: str, name: str, token: str):
url = f"{settings.APP_URL}/reset-password?token={token}"
subject = "Reset your PedQuiz password"
md = f"""# Reset your password
Hi **{name}**,
We received a request to reset your password. Click below to choose a new one.
[button:Reset Password]({url})
> Expires in **1 hour** · Single use only. If you didn't request this, ignore this email — your account is safe.
---
[link:Or copy this link]({url})
"""
await _send(to_email, subject, _wrap(subject, md))
async def send_reminder_email(email: str, user_name: str, quiz_title: str, score: float, next_date: str):
subject = f"Time to review: {quiz_title}"
note = "Great work — keep the streak going! 🌟" if score >= 75 else "A bit more practice will get you there. 📚"
md = f"""# Time to review
Hi **{user_name}**,
Your spaced repetition schedule says it's time to revisit **{quiz_title}**.
## Last score
**{score:.0f}%** {note}
Reviewing at spaced intervals is one of the most effective ways to retain medical knowledge long-term. Each session strengthens the memory trace.
[button:Take Quiz Now]({settings.APP_URL}/quizzes)
---
You're receiving this because you have active quiz reminders. To stop, disable reminders in your account settings.
"""
await _send(email, subject, _wrap(subject, md))

View file

@ -0,0 +1,86 @@
"""Embedding generation for semantic search via pgvector.
Priority:
1. AWS Bedrock Titan Embed V2 (direct, best quality, 1024 dims)
2. LiteLLM proxy with configured LITELLM_EMBEDDING_MODEL (fallback)
"""
import logging
from app.config import settings
logger = logging.getLogger(__name__)
def _text_for_question(question_text: str, options: list[str] | None) -> str:
"""Build the text to embed for a question — stem + options, no explanation."""
parts = [question_text]
if options:
parts.extend(options)
return " ".join(parts)[:4000]
def generate_embedding(text: str) -> list[float] | None:
"""Generate a 1024-dim embedding.
Priority:
1. LiteLLM proxy (openai/titan-embed-v2) scores ~0.71 cosine similarity
2. AWS Bedrock direct fallback, scores ~0.48
"""
clean = " ".join(text.split())[:4000]
if not clean:
return None
# ── 1. LiteLLM proxy (best quality) ────────────────────────
if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY:
try:
import litellm
resp = litellm.embedding(
model=settings.LITELLM_EMBEDDING_MODEL,
input=[clean],
api_key=settings.LITELLM_API_KEY,
api_base=settings.LITELLM_API_BASE or None,
)
emb = resp.data[0]["embedding"]
if len(emb) == settings.EMBEDDING_DIMENSIONS:
return emb
# Dimension mismatch — don't store incompatible vector
logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}")
except Exception as e:
logger.warning(f"LiteLLM embedding failed: {e}")
# ── 2. AWS Bedrock Titan direct (fallback) ──────────────────
if settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY:
try:
import boto3, json
client = boto3.client(
"bedrock-runtime",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
region_name=settings.AWS_BEDROCK_REGION or "us-east-1",
)
body = json.dumps({
"inputText": clean,
"dimensions": settings.EMBEDDING_DIMENSIONS,
"normalize": True,
})
resp = client.invoke_model(
modelId="amazon.titan-embed-text-v2:0",
body=body,
contentType="application/json",
accept="application/json",
)
return json.loads(resp["body"].read())["embedding"]
except Exception as e:
logger.warning(f"Bedrock embedding failed: {e}")
return None
def embed_question(question) -> bool:
"""Generate and store embedding for a Question ORM object. Returns True on success."""
text = _text_for_question(question.question_text, question.options)
emb = generate_embedding(text)
if emb:
question.embedding = emb
return True
return False

View file

@ -8,7 +8,7 @@ from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.models.quiz import Quiz
from app.models.question import Question
from app.services import ai_service, vector_service, pdf_service
from app.services import ai_service, vector_service, pdf_service, embedding_service
logger = logging.getLogger(__name__)
@ -20,6 +20,7 @@ def create_quiz_from_section(
title: str,
mode: str = "timed",
time_limit_minutes: int | None = None,
model_id: str | None = None,
) -> Quiz:
"""Extract questions from a section's page range using AI."""
section = db.query(Section).filter(Section.id == section_id).first()
@ -40,8 +41,13 @@ def create_quiz_from_section(
if not content:
raise ValueError("No content found for this section's page range")
# Get configured model
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
# Get configured model (use override if provided)
if model_id:
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
api_key = config.api_key if config and config.api_key else None
else:
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
# Extract questions via AI (not generate — questions already exist in PDF)
page_info = f"{section.start_page}-{section.end_page}"
@ -63,20 +69,28 @@ def create_quiz_from_section(
except Exception as e:
logger.warning(f"Image extraction failed: {e}")
# Collect skipped questions (those without a correct answer)
import json
skipped = []
if question_data and question_data[0].get("skipped"):
skipped = question_data[0].pop("skipped")
valid_questions = [q for q in question_data if q.get("correct_answer")]
# Create quiz
quiz = Quiz(
section_id=section_id,
user_id=user_id,
title=title,
questions_count=len(question_data),
questions_count=len(valid_questions),
mode=mode,
time_limit_minutes=time_limit_minutes,
skipped_questions=json.dumps(skipped) if skipped else None,
)
db.add(quiz)
db.flush()
# Create question records, associating images where possible
for q in question_data:
for q in valid_questions:
page_ref = q.get("page_reference")
image_path = None
@ -98,6 +112,11 @@ def create_quiz_from_section(
image_path=image_path,
)
db.add(question)
db.flush()
try:
embedding_service.embed_question(question)
except Exception as e:
logger.warning(f"Embedding generation failed for question {question.id}: {e}")
db.commit()
db.refresh(quiz)

View file

@ -41,8 +41,8 @@ def update_reminder_schedule(
current_idx = i
break
if score_percentage < 70:
# Poor performance: reset to shortest interval
if score_percentage < 75:
# Below threshold: reset to shortest interval
new_idx = 0
elif score_percentage < 90:
# Decent performance: advance one step

View file

@ -1,10 +1,28 @@
import chromadb
from chromadb import EmbeddingFunction, Embeddings
from app.config import settings
_client = None
class LiteLLMEmbeddingFunction(EmbeddingFunction):
"""ChromaDB embedding function backed by LiteLLM."""
def __call__(self, input: list[str]) -> Embeddings:
import litellm
kwargs = {
"model": settings.LITELLM_EMBEDDING_MODEL,
"input": input,
}
if 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.embedding(**kwargs)
return [item["embedding"] for item in response.data]
def get_client() -> chromadb.PersistentClient:
global _client
if _client is None:
@ -14,7 +32,13 @@ def get_client() -> chromadb.PersistentClient:
def get_or_create_collection(document_id: int):
client = get_client()
return client.get_or_create_collection(name=f"doc_{document_id}")
# Only use custom embedding if model is configured and has a provider prefix
use_ef = bool(settings.LITELLM_EMBEDDING_MODEL and "/" in settings.LITELLM_EMBEDDING_MODEL)
ef = LiteLLMEmbeddingFunction() if use_ef else None
kwargs = {"name": f"doc_{document_id}"}
if ef:
kwargs["embedding_function"] = ef
return client.get_or_create_collection(**kwargs)
def delete_collection(document_id: int):

View file

@ -6,6 +6,7 @@ celery_app = Celery(
"quiz_tasks",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL,
include=["app.tasks.pdf_tasks"],
)
celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json"

100
backend/manage.py Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""PedQuiz management CLI.
Usage (inside container):
python manage.py reset-password <email> <new-password>
python manage.py list-users
python manage.py reembed # Regenerate all question embeddings
"""
import argparse
import os
import sys
# Ensure app is importable
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def reset_password(email: str, new_password: str):
from app.database import SessionLocal
from app.models.user import User
from app.utils.auth import get_password_hash
if len(new_password) < 8:
print("Error: Password must be at least 8 characters")
sys.exit(1)
db = SessionLocal()
try:
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"Error: No user found with email '{email}'")
sys.exit(1)
user.hashed_password = get_password_hash(new_password)
db.commit()
print(f"Password reset for {user.email} (name={user.name!r}, role={user.role})")
finally:
db.close()
def list_users():
from app.database import SessionLocal
from app.models.user import User
from app.models.email_verification import EmailVerification
db = SessionLocal()
try:
users = db.query(User).order_by(User.created_at).all()
print(f"{'ID':>4} {'Email':40} {'Role':12} {'Verified':8} Name")
print("-" * 90)
for u in users:
v = db.query(EmailVerification).filter(EmailVerification.user_id == u.id).first()
verified = "yes" if (v and v.verified_at) else "no"
print(f"{u.id:>4} {u.email:40} {u.role:12} {verified:8} {u.name}")
finally:
db.close()
def reembed():
"""Regenerate embeddings for all questions using current embedding model."""
from app.database import SessionLocal
from app.models.question import Question
from app.services import embedding_service
db = SessionLocal()
try:
questions = db.query(Question).all()
print(f"Re-embedding {len(questions)} questions...")
ok = fail = 0
for q in questions:
q.embedding = None # force regeneration
if embedding_service.embed_question(q):
ok += 1
else:
fail += 1
db.commit()
print(f"Done: {ok} succeeded, {fail} failed")
finally:
db.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="PedQuiz management CLI")
sub = parser.add_subparsers(dest="command")
rp = sub.add_parser("reset-password", help="Reset a user password")
rp.add_argument("email", help="User email address")
rp.add_argument("password", help="New password (min 8 chars)")
sub.add_parser("list-users", help="List all users with verification status")
sub.add_parser("reembed", help="Regenerate all question embeddings")
args = parser.parse_args()
if args.command == "reset-password":
reset_password(args.email, args.password)
elif args.command == "list-users":
list_users()
elif args.command == "reembed":
reembed()
else:
parser.print_help()

View file

@ -1,9 +1,11 @@
fastapi==0.109.2
psycopg2-binary==2.9.9
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
python-multipart==0.0.9
pydantic[email]==2.6.1
pydantic-settings==2.1.0
@ -17,3 +19,5 @@ apscheduler==3.10.4
aiofiles==23.2.1
python-dotenv==1.0.1
httpx==0.27.0
boto3==1.34.69
pgvector==0.3.6

View file

@ -1,28 +1,41 @@
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_DB: pedquiz
POSTGRES_USER: pedquiz
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedquiz"]
interval: 5s
timeout: 5s
retries: 10
frontend:
build: ./frontend
ports:
- "80:80"
- "127.0.0.1:8081:80"
depends_on:
- backend
restart: unless-stopped
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
- sqlite_data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/quiz.db
depends_on:
- redis
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
celery:
@ -33,11 +46,11 @@ services:
volumes:
- uploads_data:/app/uploads
- chroma_data:/app/chroma_data
- sqlite_data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/quiz.db
depends_on:
- redis
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
redis:
@ -49,5 +62,5 @@ services:
volumes:
uploads_data:
chroma_data:
sqlite_data:
postgres_data:
redis_data:

View file

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Quiz Generator</title>
<title>PedQuiz</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🩺</text></svg>" />
</head>
<body>
<div id="root"></div>

View file

@ -6,7 +6,9 @@ server {
# API proxy to backend
location /api/ {
proxy_pass http://backend:8000;
resolver 127.0.0.11 valid=10s;
set $backend http://backend:8000;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
@ -10,6 +11,12 @@ import QuizPage from './pages/QuizPage'
import QuizzesPage from './pages/QuizzesPage'
import ResultsPage from './pages/ResultsPage'
import AdminPage from './pages/AdminPage'
import AccountPage from './pages/AccountPage'
import SettingsPage from './pages/SettingsPage'
import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
function ProtectedRoute({ children, requireModerator = false }) {
const { user, loading } = useAuth()
@ -19,6 +26,16 @@ function ProtectedRoute({ children, requireModerator = false }) {
return children
}
function Footer() {
return (
<footer className="site-footer">
<div className="container">
© {new Date().getFullYear()} PedQuiz
</div>
</footer>
)
}
function AppRoutes() {
const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner"></div></div>
@ -30,6 +47,9 @@ function AppRoutes() {
<Routes>
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
@ -37,8 +57,12 @@ function AppRoutes() {
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
<Route path="/quizzes/:id/edit" element={<ProtectedRoute requireModerator><QuizEditPage /></ProtectedRoute>} />
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
</Routes>
</div>
<Footer />
</>
)
}
@ -46,9 +70,11 @@ function AppRoutes() {
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
}

View file

@ -0,0 +1,71 @@
export default function LineChart({ data, width = 500, height = 180 }) {
if (!data || data.length < 2) {
return (
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
Need at least 2 attempts to show a graph
</div>
)
}
const pad = { top: 16, right: 16, bottom: 32, left: 40 }
const W = width - pad.left - pad.right
const H = height - pad.top - pad.bottom
const minY = 0, maxY = 100
const xStep = W / (data.length - 1)
const toX = (i) => pad.left + i * xStep
const toY = (v) => pad.top + H - ((v - minY) / (maxY - minY)) * H
// Line path
const points = data.map((d, i) => `${toX(i)},${toY(d.percentage)}`)
const linePath = `M ${points.join(' L ')}`
// Fill path
const fillPath = `M ${toX(0)},${toY(0)} L ${points.join(' L ')} L ${toX(data.length - 1)},${toY(0)} Z`
// Y gridlines
const gridLines = [0, 25, 50, 75, 100]
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>
{/* Grid lines */}
{gridLines.map(v => (
<g key={v}>
<line
x1={pad.left} y1={toY(v)} x2={pad.left + W} y2={toY(v)}
stroke={v === 75 ? '#fde68a' : '#f1f5f9'} strokeWidth={v === 75 ? 1.5 : 1}
strokeDasharray={v === 75 ? '4 3' : 'none'}
/>
<text x={pad.left - 6} y={toY(v)} textAnchor="end" dominantBaseline="middle"
fontSize="10" fill="#94a3b8">{v}%</text>
</g>
))}
{/* 75% label */}
<text x={pad.left + W + 4} y={toY(75)} dominantBaseline="middle" fontSize="9" fill="#d97706">75%</text>
{/* Fill */}
<path d={fillPath} fill="#e0e7ff" opacity="0.4" />
{/* Line */}
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{/* Dots + tooltips */}
{data.map((d, i) => {
const x = toX(i), y = toY(d.percentage)
const color = d.percentage >= 75 ? '#22c55e' : '#ef4444'
const label = new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })
return (
<g key={i}>
<circle cx={x} cy={y} r="4" fill={color} stroke="white" strokeWidth="2" />
{/* X axis label */}
<text x={x} y={pad.top + H + 16} textAnchor="middle" fontSize="9" fill="#94a3b8">{label}</text>
{/* Hover tooltip via title */}
<title>{label}: {d.percentage}% ({d.score}/{d.total})</title>
</g>
)
})}
</svg>
)
}

View file

@ -8,25 +8,13 @@ export default function Navbar() {
return (
<div className="navbar">
<div className="container">
<Link to="/" className="logo">PDF Quiz</Link>
<Link to="/" className="logo">🩺 PedQuiz</Link>
{user && (
<nav>
<Link to="/">Dashboard</Link>
<Link to="/quizzes">Quizzes</Link>
{isModerator && <Link to="/upload">Upload PDF</Link>}
{user.role === 'admin' && <Link to="/admin" style={{ color: '#fbbf24' }}>Admin</Link>}
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
{user.name}
{user.role !== 'user' && (
<span style={{
marginLeft: 6, fontSize: '0.7rem',
background: user.role === 'admin' ? '#ef4444' : '#7c3aed',
color: 'white', padding: '1px 6px', borderRadius: 10
}}>
{user.role}
</span>
)}
</span>
<Link to="/settings" title="Settings"> Settings</Link>
<button onClick={logout}>Logout</button>
</nav>
)}

View file

@ -27,9 +27,8 @@ export function AuthProvider({ children }) {
return me.data
}
const register = async (email, password, name) => {
const res = await api.post('/auth/register', { email, password, name })
localStorage.setItem('token', res.data.access_token)
const loginWithToken = async (token) => {
localStorage.setItem('token', token)
const me = await api.get('/auth/me')
setUser(me.data)
return me.data
@ -41,7 +40,7 @@ export function AuthProvider({ children }) {
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
<AuthContext.Provider value={{ user, loading, login, loginWithToken, logout }}>
{children}
</AuthContext.Provider>
)

View file

@ -0,0 +1,22 @@
import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => localStorage.getItem('pedquiz_theme') || 'default')
useEffect(() => {
document.body.setAttribute('data-theme', theme)
localStorage.setItem('pedquiz_theme', theme)
}, [theme])
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}

View file

@ -1,418 +1,311 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
/* ── CSS Variables (themes) ─────────────────────────────────── */
:root {
--bg: #f1f5f9;
--card-bg: #ffffff;
--card-border: 1px solid transparent;
--card-shadow: 0 1px 4px rgba(0,0,0,0.08);
--card-radius: 12px;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-fg: #ffffff;
--text: #1e293b;
--text-muted: #64748b;
--text-subtle: #94a3b8;
--border: #e2e8f0;
--input-bg: #ffffff;
--option-bg: #ffffff;
--option-hover: #f8fafc;
--option-sel-bg: #eff6ff;
--option-sel-bd: #2563eb;
--correct-bg: #d1fae5;
--correct-bd: #86efac;
--correct-fg: #14532d;
--wrong-bg: #fee2e2;
--wrong-bd: #fca5a5;
--wrong-fg: #7f1d1d;
--expl-bg: #f0f9ff;
--expl-bd: #38bdf8;
--navbar-bg: #0f172a;
--navbar-fg: #e2e8f0;
--badge-radius: 12px;
}
[data-theme="markdown"] {
--bg: #eaeef2;
--card-bg: #ffffff;
--card-border: 1px solid #d0d7de;
--card-shadow: none;
--card-radius: 6px;
--primary: #0969da;
--primary-hover: #0757ba;
--primary-fg: #ffffff;
--text: #1f2328;
--text-muted: #57606a;
--text-subtle: #8c959f;
--border: #d0d7de;
--input-bg: #f6f8fa;
--option-bg: #ffffff;
--option-hover: #f6f8fa;
--option-sel-bg: #ddf4ff;
--option-sel-bd: #0969da;
--correct-bg: #dafbe1;
--correct-bd: #82cfb5;
--correct-fg: #116329;
--wrong-bg: #ffebe9;
--wrong-bd: #ff9d8a;
--wrong-fg: #82071e;
--expl-bg: #f6f8fa;
--expl-bd: #0969da;
--navbar-bg: #24292f;
--navbar-fg: #e6edf3;
--badge-radius: 2em;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
--font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
/* ── Markdown theme typography overrides ───────────────────── */
body[data-theme="markdown"] {
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
font-size: 16px;
}
[data-theme="markdown"] .card h2,
[data-theme="markdown"] h1,
[data-theme="markdown"] h2,
[data-theme="markdown"] h3 {
font-family: var(--font-heading);
font-weight: 600;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
margin-bottom: 16px;
}
[data-theme="markdown"] .question-card h3 { border-bottom: none; padding-bottom: 0; font-size: 1.1rem; }
[data-theme="markdown"] .question-card { border-left: 4px solid var(--primary); }
[data-theme="markdown"] .explanation {
font-family: var(--font-body); line-height: 1.8;
border: 1px solid var(--border); border-left: 4px solid var(--expl-bd);
background: var(--expl-bg);
}
[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.95rem; }
[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: monospace; }
[data-theme="markdown"] .card { border-radius: 6px; }
[data-theme="markdown"] .btn { font-size: 0.875rem; }
[data-theme="markdown"] .review-card { border-radius: 6px; }
[data-theme="markdown"] .score-display .score-value { letter-spacing: -3px; }
/* ── Reset ──────────────────────────────────────────────────── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background 0.25s, color 0.25s;
overflow-x: hidden;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 20px;
}
/* ── Layout ─────────────────────────────────────────────────── */
.container { max-width: 1100px; margin: 0 auto; padding: 0 20px; }
/* Navigation */
.navbar {
background: #1e293b;
color: white;
padding: 12px 0;
margin-bottom: 24px;
}
/* ── Navbar ─────────────────────────────────────────────────── */
.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 12px 0; margin-bottom: 28px; }
.navbar .container { display: flex; justify-content: space-between; align-items: center; }
.navbar .logo { font-size: 1.2rem; font-weight: 700; color: #60a5fa; text-decoration: none; }
[data-theme="minimal"] .navbar .logo { color: #a1a1aa; }
.navbar nav { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.88rem; opacity: 0.75; }
.navbar a:hover { opacity: 1; }
.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.18); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar .logo {
font-size: 1.3rem;
font-weight: 700;
color: #60a5fa;
text-decoration: none;
}
.navbar nav {
display: flex;
gap: 16px;
align-items: center;
}
.navbar a {
color: #cbd5e1;
text-decoration: none;
font-size: 0.9rem;
}
.navbar a:hover {
color: white;
}
.navbar button {
background: transparent;
border: 1px solid #475569;
color: #cbd5e1;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
/* Cards */
/* ── Cards ──────────────────────────────────────────────────── */
.card {
background: white;
border-radius: 10px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
background: var(--card-bg);
border: var(--card-border);
border-radius: var(--card-radius);
padding: 28px;
box-shadow: var(--card-shadow);
margin-bottom: 16px;
}
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
.card h2 {
margin-bottom: 16px;
font-size: 1.2rem;
}
/* Buttons */
/* ── Buttons ────────────────────────────────────────────────── */
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: background 0.2s;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #e2e8f0;
color: #334155;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm {
padding: 6px 14px;
font-size: 0.82rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 6px;
font-size: 0.9rem;
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 20px; border-radius: 8px; border: none;
cursor: pointer; font-size: 0.875rem; font-weight: 500;
text-decoration: none; transition: background 0.15s, opacity 0.15s; white-space: nowrap;
}
[data-theme="minimal"] .btn { border-radius: 6px; }
.btn-primary { background: var(--primary); color: var(--primary-fg); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { background: var(--border); color: var(--text); }
.btn-secondary:hover { filter: brightness(0.94); }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 5px 12px; font-size: 0.78rem; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
/* ── Forms ──────────────────────────────────────────────────── */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 500; margin-bottom: 6px; font-size: 0.85rem; color: var(--text-muted); }
.form-group input,
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
.form-group select,
.form-group textarea {
width: 100%; padding: 9px 13px;
border: 1px solid var(--border); border-radius: 8px;
font-size: 0.9rem; background: var(--input-bg); color: var(--text);
font-family: inherit;
}
[data-theme="minimal"] .form-group input,
[data-theme="minimal"] .form-group select,
[data-theme="minimal"] .form-group textarea { border-radius: 6px; background: var(--input-bg); }
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
[data-theme="minimal"] .form-group input:focus { box-shadow: 0 0 0 2px #09090b18; }
.form-group input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
/* Status badges */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-processing { background: #fef3c7; color: #92400e; }
.badge-ready { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
/* Quiz question styles */
/* ── Question card (quiz-taking) ────────────────────────────── */
.question-card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
border-left: 4px solid #2563eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.question-card h3 {
font-size: 1rem;
margin-bottom: 12px;
}
.question-card .options {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--card-bg);
border: var(--card-border);
border-left: 4px solid var(--primary);
border-radius: var(--card-radius);
padding: 28px 28px 24px;
margin-bottom: 20px;
box-shadow: var(--card-shadow);
}
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
/* Options */
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
.question-card .option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex; align-items: center; gap: 12px;
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
cursor: pointer; transition: border-color 0.12s, background 0.12s;
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
}
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
.question-card .option.selected { border-color: var(--option-sel-bd); background: var(--option-sel-bg); }
.question-card .option.correct { border-color: var(--correct-bd); background: var(--correct-bg); color: var(--correct-fg); }
.question-card .option.incorrect { border-color: var(--wrong-bd); background: var(--wrong-bg); color: var(--wrong-fg); }
.question-card .option:hover {
background: #f1f5f9;
}
.question-card .option.selected {
border-color: #2563eb;
background: #eff6ff;
}
.question-card .option.correct {
border-color: #22c55e;
background: #f0fdf4;
}
.question-card .option.incorrect {
border-color: #ef4444;
background: #fef2f2;
}
.question-card input[type="text"] {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
.option-letter {
display: inline-flex; align-items: center; justify-content: center;
width: 27px; height: 27px; border-radius: 50%; flex-shrink: 0;
font-size: 0.75rem; font-weight: 700; background: var(--border); color: var(--text-muted);
transition: background 0.12s;
}
.option.selected .option-letter { background: var(--primary); color: white; }
.option.correct .option-letter { background: #16a34a; color: white; }
.option.incorrect .option-letter { background: #dc2626; color: white; }
/* Explanation */
.explanation {
margin-top: 12px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
font-size: 0.85rem;
color: #475569;
border-left: 3px solid #60a5fa;
margin-top: 20px; padding: 18px 20px;
background: var(--expl-bg); border-left: 3px solid var(--expl-bd);
border-radius: 8px; font-size: 0.875rem; color: var(--text-muted);
line-height: 1.75; white-space: pre-line;
}
.explanation strong { color: var(--text); font-weight: 600; }
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
/* ── Review card (results) ──────────────────────────────────── */
.review-card {
background: var(--card-bg); border: var(--card-border);
border-radius: var(--card-radius); padding: 28px 28px 24px;
margin-bottom: 20px; box-shadow: var(--card-shadow);
border-left: 4px solid var(--border);
}
.review-card.correct-card { border-left-color: #22c55e; }
.review-card.wrong-card { border-left-color: #ef4444; }
.review-card.skipped-card { border-left-color: var(--text-subtle); }
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
text-align: center;
}
/* ── Badges ─────────────────────────────────────────────────── */
.badge { display: inline-block; padding: 3px 10px; border-radius: var(--badge-radius); font-size: 0.72rem; font-weight: 600; }
.badge-processing { background: #fef3c7; color: #92400e; }
.badge-ready { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: #2563eb;
}
/* ── Stats ──────────────────────────────────────────────────── */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat-card { background: var(--card-bg); border: var(--card-border); padding: 24px; border-radius: var(--card-radius); box-shadow: var(--card-shadow); text-align: center; }
.stat-card .stat-value { font-size: 2.2rem; font-weight: 800; color: var(--primary); }
.stat-card .stat-label { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
.stat-card .stat-label {
font-size: 0.85rem;
color: #64748b;
margin-top: 4px;
}
/* ── Grid ───────────────────────────────────────────────────── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* Grid layouts */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Alerts */
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.alert-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert-success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
/* Score display */
.score-display {
text-align: center;
padding: 32px;
}
.score-display .score-value {
font-size: 3.5rem;
font-weight: 800;
}
/* ── Alerts ─────────────────────────────────────────────────── */
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
.alert-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.alert-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
/* ── Score display ──────────────────────────────────────────── */
.score-display { text-align: center; padding: 40px 24px; }
.score-display .score-value { font-size: 4.5rem; font-weight: 900; line-height: 1; letter-spacing: -2px; }
.score-display .score-value.good { color: #22c55e; }
.score-display .score-value.ok { color: #f59e0b; }
.score-display .score-value.ok { color: #f59e0b; }
.score-display .score-value.poor { color: #ef4444; }
/* Upload area */
.upload-area {
border: 2px dashed #cbd5e1;
border-radius: 12px;
padding: 48px;
/* ── Upload ─────────────────────────────────────────────────── */
.upload-area { border: 2px dashed var(--border); border-radius: 12px; padding: 56px; text-align: center; cursor: pointer; transition: all 0.2s; }
.upload-area:hover, .upload-area.dragging { border-color: var(--primary); background: var(--option-sel-bg); }
/* ── Progress bar ───────────────────────────────────────────── */
.progress-bar { background: var(--border); border-radius: 999px; height: 6px; overflow: hidden; }
.progress-bar .fill { background: var(--primary); height: 100%; border-radius: 999px; transition: width 0.3s; }
/* ── Spinner ────────────────────────────────────────────────── */
.spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Section items ──────────────────────────────────────────── */
.section-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: var(--card-bg); }
/* ── Auth pages ─────────────────────────────────────────────── */
.auth-page { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
.auth-card { background: var(--card-bg); padding: 40px; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,0.08); width: 100%; max-width: 420px; border: var(--card-border); }
.auth-card h1 { text-align: center; margin-bottom: 24px; color: var(--text); }
.auth-card .auth-link { text-align: center; margin-top: 14px; font-size: 0.875rem; color: var(--text-muted); }
.auth-card .auth-link a { color: var(--primary); text-decoration: none; }
/* ── Loading / empty ────────────────────────────────────────── */
.loading { text-align: center; padding: 56px; color: var(--text-muted); }
.empty-state { text-align: center; padding: 48px; color: var(--text-subtle); }
/* ── Footer ─────────────────────────────────────────────────── */
.site-footer {
text-align: center;
cursor: pointer;
transition: all 0.2s;
padding: 24px 0 32px;
margin-top: 48px;
border-top: 1px solid var(--border);
color: var(--text-subtle);
font-size: 0.8rem;
}
.upload-area:hover {
border-color: #2563eb;
background: #f8fafc;
}
.upload-area.dragging {
border-color: #2563eb;
background: #eff6ff;
}
/* Progress bar */
.progress-bar {
background: #e2e8f0;
border-radius: 999px;
height: 8px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar .fill {
background: #2563eb;
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
/* Spinner */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e2e8f0;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Section list */
.section-item {
/* ── Quiz page bottom spacing ───────────────────────────────── */
.quiz-bottom { padding-bottom: 48px; }
.quiz-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
}
/* Auth pages */
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.auth-card {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
width: 100%;
max-width: 420px;
}
.auth-card h1 {
text-align: center;
margin-bottom: 24px;
color: #1e293b;
}
.auth-card .auth-link {
text-align: center;
margin-top: 16px;
font-size: 0.9rem;
}
.auth-card .auth-link a {
color: #2563eb;
text-decoration: none;
}
/* Loading */
.loading {
text-align: center;
padding: 48px;
color: #64748b;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 48px;
color: #94a3b8;
gap: 8px;
}
.quiz-nav-numbers {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
flex: 1;
min-width: 0;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
.grid-2 { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.question-card, .review-card { padding: 20px 16px; }
.card { padding: 20px; }
.quiz-nav { flex-wrap: wrap; }
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
}

View file

@ -0,0 +1,129 @@
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function AccountPage() {
const { user, login } = useAuth()
const [name, setName] = useState(user?.name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
if (newPassword && newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword && newPassword.length < 8) {
setError('New password must be at least 8 characters')
return
}
setLoading(true)
try {
const payload = {}
if (name !== user.name) payload.name = name
if (newPassword) {
payload.current_password = currentPassword
payload.new_password = newPassword
}
if (!Object.keys(payload).length) {
setError('No changes to save')
setLoading(false)
return
}
await api.put('/auth/me', payload)
setSuccess('Account updated successfully')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
// Refresh user info
const res = await api.get('/auth/me')
// Update local token display by refreshing
window.location.reload()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update account')
} finally {
setLoading(false)
}
}
return (
<div style={{ maxWidth: 520, margin: '0 auto' }}>
<div className="card">
<h2>My Account</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: 4 }}>
{user?.email} &middot;
<span style={{
marginLeft: 6, fontSize: '0.75rem', fontWeight: 600,
background: user?.role === 'admin' ? '#fee2e2' : user?.role === 'moderator' ? '#ede9fe' : '#dbeafe',
color: user?.role === 'admin' ? '#dc2626' : user?.role === 'moderator' ? '#7c3aed' : '#2563eb',
padding: '1px 8px', borderRadius: 10,
}}>{user?.role}</span>
</p>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
<div className="card">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<hr style={{ border: 'none', borderTop: '1px solid #e2e8f0', margin: '20px 0' }} />
<p style={{ fontWeight: 600, fontSize: '0.9rem', color: '#374151', margin: '0 0 12px' }}>
Change Password <span style={{ fontWeight: 400, color: '#94a3b8' }}>(leave blank to keep current)</span>
</p>
<div className="form-group">
<label>Current Password</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Required to change password"
/>
</div>
<div className="form-group">
<label>New Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
/>
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
</div>
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 8 }}>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</form>
</div>
</div>
)
}

View file

@ -4,24 +4,6 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TASKS = ['extraction', 'tts', 'general']
const PRESET_MODELS = {
extraction: [
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
{ name: 'GPT-4o', model_id: 'gpt-4o' },
{ name: 'Claude Sonnet 4.6', model_id: 'anthropic/claude-sonnet-4-6-20250514' },
{ name: 'Claude Haiku 4.5', model_id: 'anthropic/claude-haiku-4-5-20251001' },
{ name: 'Gemini Pro', model_id: 'gemini/gemini-1.5-pro' },
],
tts: [
{ name: 'Google Vertex TTS', model_id: 'vertex_ai/google/cloud-tts' },
{ name: 'OpenAI TTS-1', model_id: 'openai/tts-1' },
{ name: 'OpenAI TTS-1 HD', model_id: 'openai/tts-1-hd' },
],
general: [
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
{ name: 'GPT-4o', model_id: 'gpt-4o' },
],
}
export default function AdminPage() {
const { user } = useAuth()
@ -34,6 +16,15 @@ export default function AdminPage() {
const [success, setSuccess] = useState('')
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
// LiteLLM search state
const [searchApiKey, setSearchApiKey] = useState('')
const [searchApiBase, setSearchApiBase] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('')
const [searchFilter, setSearchFilter] = useState('')
useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
@ -108,8 +99,28 @@ export default function AdminPage() {
}
}
const applyPreset = (preset) => {
setNewModel(m => ({ ...m, name: preset.name, model_id: preset.model_id }))
const searchLiteLLM = async () => {
setSearchError('')
setSearchResults([])
setSearchLoading(true)
try {
const params = new URLSearchParams()
if (searchApiKey) params.set('api_key', searchApiKey)
if (searchApiBase) params.set('api_base', searchApiBase)
const res = await api.get(`/admin/litellm/models?${params}`)
setSearchResults(res.data.models)
} catch (err) {
setSearchError(err.response?.data?.detail || 'Failed to query models')
} finally {
setSearchLoading(false)
}
}
const addFromSearch = (modelId) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
setSearchResults([])
// scroll to add form
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
@ -119,6 +130,10 @@ export default function AdminPage() {
return acc
}, {})
const filteredResults = searchFilter
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
: searchResults
return (
<div>
<div className="card">
@ -137,13 +152,83 @@ export default function AdminPage() {
{tab === 'models' && (
<>
{/* LiteLLM search */}
<div className="card">
<h2>Search Models from LiteLLM</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
Query an OpenAI-compatible API (or your LiteLLM proxy) to see available models, then click one to add it.
</p>
<div className="grid-2">
<div className="form-group">
<label>API Base URL (optional)</label>
<input
type="text"
value={searchApiBase}
onChange={e => setSearchApiBase(e.target.value)}
placeholder="e.g. https://api.openai.com or LiteLLM proxy URL"
/>
</div>
<div className="form-group">
<label>API Key (optional uses .env if blank)</label>
<input
type="password"
value={searchApiKey}
onChange={e => setSearchApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
</div>
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
{searchLoading ? 'Searching...' : 'Search Models'}
</button>
{searchError && <div className="alert alert-error" style={{ marginTop: 12 }}>{searchError}</div>}
{searchResults.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong>{searchResults.length} models found</strong>
<input
type="text"
placeholder="Filter..."
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }}
/>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 8 }}>
{filteredResults.map(modelId => (
<div
key={modelId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid #f1f5f9',
fontSize: '0.85rem', fontFamily: 'monospace',
}}
>
<span>{modelId}</span>
<button
className="btn btn-primary btn-sm"
onClick={() => addFromSearch(modelId)}
style={{ fontFamily: 'inherit' }}
>
+ Add
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* Configured models by task */}
{TASKS.map(task => (
<div className="card" key={task}>
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
{task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech for reading questions' :
task === 'tts' ? '— Text-to-speech voices' :
'— General purpose AI'}
</span>
</h2>
@ -173,7 +258,8 @@ export default function AdminPage() {
</div>
))}
<div className="card">
{/* Add model form */}
<div className="card" id="add-model-form">
<h2>Add Model</h2>
<form onSubmit={createModel}>
<div className="grid-2">
@ -183,15 +269,6 @@ export default function AdminPage() {
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-group">
<label>Preset (optional)</label>
<select onChange={e => { if (e.target.value) applyPreset(JSON.parse(e.target.value)) }}>
<option value=""> select preset </option>
{(PRESET_MODELS[newModel.task] || []).map(p => (
<option key={p.model_id} value={JSON.stringify(p)}>{p.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
@ -218,6 +295,35 @@ export default function AdminPage() {
)}
{tab === 'users' && (
<>
<div className="card">
<h2>Add User</h2>
<form onSubmit={async e => {
e.preventDefault(); setError(''); setSuccess('')
try {
await api.post('/admin/users', newUser)
setSuccess(`User ${newUser.email} created`)
setNewUser({ name: '', email: '', password: '' })
loadData()
} catch (err) { setError(err.response?.data?.detail || 'Failed to create user') }
}}>
<div className="grid-2">
<div className="form-group">
<label>Name</label>
<input type="text" value={newUser.name} onChange={e => setNewUser(u => ({ ...u, name: e.target.value }))} required />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={newUser.email} onChange={e => setNewUser(u => ({ ...u, email: e.target.value }))} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={newUser.password} onChange={e => setNewUser(u => ({ ...u, password: e.target.value }))} required minLength={8} />
</div>
</div>
<button className="btn btn-primary" type="submit">Create User</button>
</form>
</div>
<div className="card">
<h2>Users</h2>
{users.map(u => (
@ -243,6 +349,7 @@ export default function AdminPage() {
</div>
))}
</div>
</>
)}
</div>
)

View file

@ -1,45 +1,103 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import LineChart from '../components/LineChart'
import { useAuth } from '../context/AuthContext'
function greeting(name) {
const h = new Date().getHours()
const time = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'
const first = name?.split(' ')[0] || ''
return `${time}${first ? `, ${first}` : ''}`
}
export default function DashboardPage() {
const { user } = useAuth()
const [documents, setDocuments] = useState([])
const [stats, setStats] = useState(null)
const [history, setHistory] = useState([])
const [selectedQuizId, setSelectedQuizId] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/documents/'),
api.get('/attempts/stats/dashboard'),
]).then(([docsRes, statsRes]) => {
api.get('/attempts/history'),
]).then(([docsRes, statsRes, histRes]) => {
setDocuments(docsRes.data)
setStats(statsRes.data)
setHistory(histRes.data)
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
}).catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
const greetingText = greeting(user?.name)
const selectedQuiz = history.find(q => q.quiz_id === selectedQuizId)
return (
<div>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
</div>
{stats && (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.total_documents}</div>
<div className="stat-label">Documents</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_quizzes}</div>
<div className="stat-label">Quizzes</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total_attempts}</div>
<div className="stat-label">Attempts</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.average_score}%</div>
<div className="stat-label">Avg Score</div>
{[
{ 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' },
].map(s => (
<div className="stat-card" key={s.label}>
<div className="stat-value">{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
)}
{/* Performance graph with dropdown */}
{history.length > 0 && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ margin: 0 }}>Performance</h2>
<select
value={selectedQuizId || ''}
onChange={e => setSelectedQuizId(Number(e.target.value))}
style={{ padding: '6px 12px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', maxWidth: 280 }}
>
{history.map(q => (
<option key={q.quiz_id} value={q.quiz_id}>{q.title}</option>
))}
</select>
</div>
{selectedQuiz && (
<>
<div style={{ display: 'flex', gap: 16, marginBottom: 12, fontSize: '0.85rem', color: '#64748b' }}>
<span>{selectedQuiz.attempts.length} attempt{selectedQuiz.attempts.length !== 1 ? 's' : ''}</span>
{selectedQuiz.attempts.length > 0 && (
<>
<span>Latest: <strong style={{
color: selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage >= 75 ? '#22c55e' : '#ef4444'
}}>{selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage}%</strong></span>
<span>Best: <strong>{Math.max(...selectedQuiz.attempts.map(a => a.percentage))}%</strong></span>
</>
)}
<Link to={`/quizzes/${selectedQuiz.quiz_id}`} className="btn btn-primary btn-sm" style={{ marginLeft: 'auto' }}>Retake</Link>
</div>
<LineChart data={selectedQuiz.attempts} />
{selectedQuiz.attempts.length > 0 && selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage < 75 && (
<p style={{ margin: '8px 0 0', fontSize: '0.8rem', color: '#d97706' }}>
Below 75% a reminder will be sent to review this quiz
</p>
)}
</>
)}
</div>
)}
@ -48,11 +106,8 @@ export default function DashboardPage() {
<h2>Your 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>
<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' }}>
@ -69,28 +124,6 @@ export default function DashboardPage() {
))
)}
</div>
{stats && stats.quiz_stats.length > 0 && (
<div className="card">
<h2>Quiz Performance</h2>
{stats.quiz_stats.map(qs => (
<div className="section-item" key={qs.quiz_id}>
<div>
<strong>{qs.quiz_title}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{qs.attempts_count} attempts | Best: {qs.best_score}%
</div>
</div>
<span style={{
fontWeight: 700,
color: qs.latest_score >= 70 ? '#22c55e' : qs.latest_score >= 50 ? '#f59e0b' : '#ef4444'
}}>
{qs.latest_score}%
</span>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -15,6 +15,8 @@ export default function DocumentDetailPage() {
const [quizTitle, setQuizTitle] = useState('')
const [quizMode, setQuizMode] = useState('timed')
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
const [selectedModelId, setSelectedModelId] = useState('')
const [availableModels, setAvailableModels] = useState([])
const [error, setError] = useState('')
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@ -26,7 +28,16 @@ export default function DocumentDetailPage() {
}).catch(() => navigate('/'))
}
useEffect(() => { fetchDoc() }, [id])
useEffect(() => {
fetchDoc()
api.get('/admin/models/available?task=extraction')
.then(res => {
setAvailableModels(res.data)
const def = res.data.find(m => m.is_default)
if (def) setSelectedModelId(def.model_id)
})
.catch(() => {})
}, [id])
useEffect(() => {
if (!doc || doc.status !== 'processing') return
@ -66,6 +77,7 @@ export default function DocumentDetailPage() {
title,
mode: quizMode,
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
model_id: selectedModelId || null,
})
navigate(`/quizzes/${res.data.id}`)
} catch (err) {
@ -185,6 +197,18 @@ export default function DocumentDetailPage() {
placeholder="Leave blank for no limit" />
</div>
)}
{availableModels.length > 1 && (
<div className="form-group">
<label>Extraction Model</label>
<select value={selectedModelId} onChange={e => setSelectedModelId(e.target.value)}>
{availableModels.map(m => (
<option key={m.model_id} value={m.model_id}>
{m.name}{m.is_default ? ' (default)' : ''}
</option>
))}
</select>
</div>
)}
</div>
</div>
)}

View file

@ -0,0 +1,70 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/auth/forgot-password', { email })
setSent(true)
} catch (err) {
if (err.response?.status === 429) {
setError('Too many reset requests. Please wait before trying again.')
} else {
setError(err.response?.data?.detail || 'Something went wrong.')
}
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🩺 PedQuiz</div>
</div>
<div className="card">
{sent ? (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📧</div>
<h2 style={{ marginBottom: 8 }}>Check your email</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>
If that email is registered, we've sent a password reset link. Check your inbox (and spam folder).
</p>
<Link to="/login" style={{ color: '#2563eb', fontSize: '0.9rem' }}>Back to login</Link>
</div>
) : (
<>
<h2 style={{ marginBottom: 4 }}>Forgot password?</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>
Enter your email and we'll send you a reset link.
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required placeholder="you@example.com" />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: '0.9rem', color: '#64748b' }}>
<Link to="/login" style={{ color: '#2563eb' }}>Back to login</Link>
</p>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -42,6 +42,9 @@ export default function LoginPage() {
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="auth-link">
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
<div className="auth-link">
Don't have an account? <Link to="/register">Sign up</Link>
</div>

View file

@ -0,0 +1,219 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../api/client'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({
question_text: q.question_text,
question_type: q.question_type,
options: q.options ? [...q.options] : [],
correct_answer: q.correct_answer,
explanation: q.explanation || '',
})
const setOption = (i, val) => {
const updated = [...form.options]
// If this option was the correct answer, update correct_answer to new text
const wasCorrect = form.options[i] === form.correct_answer
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
}
const save = async () => {
if (!form.question_text.trim()) { setError('Question text is required'); return }
if (form.options.length > 0 && !form.options.includes(form.correct_answer)) {
setError('Correct answer must match one of the options exactly')
return
}
setSaving(true); setError('')
try {
const res = await api.patch(`/quizzes/${quizId}/questions/${q.id}`, form)
onSaved(res.data)
setEditing(false)
} catch (err) {
setError(err.response?.data?.detail || 'Save failed')
} finally { setSaving(false) }
}
const del = async () => {
if (!confirm('Delete this question? This cannot be undone.')) return
try {
await api.delete(`/quizzes/${quizId}/questions/${q.id}`)
onDeleted(q.id)
} catch (err) {
setError(err.response?.data?.detail || 'Delete failed')
}
}
if (!editing) {
return (
<div className="review-card" style={{ borderLeftColor: 'var(--primary)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<p style={{ flex: 1, fontWeight: 600, fontSize: '0.95rem', color: 'var(--text)', lineHeight: 1.55 }}>
{q.question_text}
</p>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
<button className="btn btn-danger btn-sm" onClick={del}>Delete</button>
</div>
</div>
{q.options && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
{q.options.map((opt, i) => (
<div key={i} style={{
display: 'flex', gap: 10, alignItems: 'center', padding: '8px 12px',
borderRadius: 6, fontSize: '0.875rem',
background: opt === q.correct_answer ? 'var(--correct-bg)' : 'var(--option-bg)',
border: `1px solid ${opt === q.correct_answer ? 'var(--correct-bd)' : 'var(--border)'}`,
color: opt === q.correct_answer ? 'var(--correct-fg)' : 'var(--text)',
}}>
<span style={{ fontWeight: 700, width: 18 }}>{LETTERS[i]}.</span>
<span style={{ flex: 1 }}>{opt}</span>
{opt === q.correct_answer && <span style={{ fontSize: '0.75rem', fontWeight: 700 }}> Correct</span>}
</div>
))}
</div>
)}
{q.explanation && (
<div className="explanation" style={{ marginTop: 14 }}>
<strong>Explanation:</strong> <span style={{ marginLeft: 4 }}>{q.explanation.slice(0, 200)}{q.explanation.length > 200 ? '…' : ''}</span>
</div>
)}
</div>
)
}
return (
<div className="card" style={{ borderLeft: '4px solid var(--primary)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<strong style={{ color: 'var(--text)' }}>Editing question</strong>
<button className="btn btn-secondary btn-sm" onClick={() => { setEditing(false); setError('') }}>Cancel</button>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} />
</div>
{form.options.length > 0 && (
<div className="form-group">
<label>Answer Options select the correct one</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="radio"
name={`correct_${q.id}`}
checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', flexShrink: 0, accentColor: 'var(--primary)' }}
title="Mark as correct"
/>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)',
}}>{LETTERS[i]}</span>
<input
type="text" value={opt}
onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }}
/>
</div>
))}
</div>
<p style={{ marginTop: 8, fontSize: '0.78rem', color: 'var(--text-subtle)' }}>
Click the radio button to mark an option as correct. Editing an option text auto-updates the correct answer if it was selected.
</p>
</div>
)}
<div className="form-group">
<label>Explanation (includes Critique and Content Specifications)</label>
<textarea rows={8} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} />
</div>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save Question'}
</button>
</div>
)
}
export default function QuizEditPage() {
const { id } = useParams()
const navigate = useNavigate()
const [quiz, setQuiz] = useState(null)
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
Promise.all([
api.get(`/quizzes/${id}`),
api.get(`/quizzes/${id}/questions`),
]).then(([qRes, questRes]) => {
setQuiz(qRes.data)
setQuestions(questRes.data)
}).catch(() => navigate('/quizzes'))
.finally(() => setLoading(false))
}, [id])
const handleSaved = (updated) => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))
}
const handleDeleted = (questionId) => {
setQuestions(prev => prev.filter(q => q.id !== questionId))
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading</div>
if (!quiz) return null
return (
<div style={{ maxWidth: 780, margin: '0 auto' }}>
<div className="card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ marginBottom: 4 }}>Edit Quiz</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{quiz.title} · {questions.length} questions</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/quizzes/${id}`} className="btn btn-secondary btn-sm"> Back to Quiz</Link>
</div>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
<p style={{ marginBottom: 16, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Click <strong>Edit</strong> on any question to modify its text, options, correct answer, or explanation.
Changes are saved per-question and take effect immediately.
</p>
{questions.length === 0 ? (
<div className="card"><div className="empty-state">No questions found.</div></div>
) : questions.map((q, idx) => (
<div key={q.id}>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}>
Question {idx + 1}
</div>
<QuestionEditor
q={q}
quizId={id}
onSaved={handleSaved}
onDeleted={handleDeleted}
/>
</div>
))}
</div>
)
}

View file

@ -1,61 +1,68 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
function TTSButton({ text }) {
const [playing, setPlaying] = useState(false)
function TTSButton({ text, voice, onActiveChange }) {
const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null)
const speak = async () => {
if (playing) {
// Stop audio when question changes (component unmounts)
useEffect(() => {
return () => {
audioRef.current?.pause()
setPlaying(false)
onActiveChange?.(false)
}
}, [])
const setStateAndNotify = (s) => {
setState(s)
onActiveChange?.(s !== 'idle')
}
const speak = async () => {
if (state === 'playing') {
audioRef.current?.pause()
setStateAndNotify('idle')
return
}
if (state === 'loading') return
try {
setPlaying(true)
const res = await api.post('/tts/speak', { text }, { responseType: 'blob' })
setStateAndNotify('loading')
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const audio = new Audio(url)
audioRef.current = audio
audio.onended = () => { setPlaying(false); URL.revokeObjectURL(url) }
audio.onerror = () => setPlaying(false)
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
audio.onerror = () => setStateAndNotify('idle')
audio.play()
} catch {
setPlaying(false)
}
setStateAndNotify('playing')
} catch { setStateAndNotify('idle') }
}
const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : '🔊 Listen'
const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff'
const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3'
return (
<button
onClick={speak}
title={playing ? 'Stop' : 'Read aloud'}
style={{
background: playing ? '#ef4444' : '#e0e7ff',
color: playing ? 'white' : '#3730a3',
border: 'none',
borderRadius: '6px',
padding: '4px 10px',
cursor: 'pointer',
fontSize: '0.8rem',
marginLeft: 8,
}}
>
{playing ? '⏹ Stop' : '🔊 Listen'}
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
background: bg, color, border: 'none', borderRadius: '6px',
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
}}>
{label}
</button>
)
}
function TimerDisplay({ seconds, total }) {
const pct = total > 0 ? (seconds / total) * 100 : 100
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
const mins = Math.floor(seconds / 60), secs = seconds % 60
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
</div>
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
@ -64,10 +71,75 @@ function TimerDisplay({ seconds, total }) {
)
}
function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const skipped = quiz.skipped_questions ? JSON.parse(quiz.skipped_questions) : []
return (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</p>
{skipped.length > 0 && (
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: 12, marginBottom: 20, textAlign: 'left' }}>
<p style={{ margin: '0 0 6px', fontWeight: 600, fontSize: '0.85rem', color: '#92400e' }}>
{skipped.length} question{skipped.length > 1 ? 's' : ''} skipped (no correct answer found):
</p>
<ul style={{ margin: 0, paddingLeft: 16 }}>
{skipped.map((q, i) => (
<li key={i} style={{ fontSize: '0.8rem', color: '#78350f', marginBottom: 2 }}>{q}</li>
))}
</ul>
</div>
)}
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Timed, answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
</div>
))}
</div>
{voices.length > 0 && (
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: 16 }}>
<label style={{ fontSize: '0.85rem', color: '#64748b', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
</select>
</div>
)}
</div>
</div>
)
}
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [quiz, setQuiz] = useState(null)
const [voices, setVoices] = useState([])
const [selectedVoice, setSelectedVoice] = useState('')
const [ttsActive, setTtsActive] = useState(false)
const [quizMode, setQuizMode] = useState(null)
const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0)
const [loading, setLoading] = useState(true)
@ -76,214 +148,232 @@ export default function QuizPage() {
const [timeLeft, setTimeLeft] = useState(null)
const [totalTime, setTotalTime] = useState(null)
const timerRef = useRef(null)
const hasStarted = useRef(false)
useEffect(() => {
const load = async () => {
try {
const quizRes = await api.get(`/quizzes/${id}`)
const [quizRes, voicesRes] = await Promise.all([
api.get(`/quizzes/${id}`),
api.get('/tts/voices').catch(() => ({ data: [] })),
])
setQuiz(quizRes.data)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
if (quizRes.data.mode === 'timed' && quizRes.data.time_limit_minutes) {
const secs = quizRes.data.time_limit_minutes * 60
setTimeLeft(secs)
setTotalTime(secs)
}
} catch {
navigate('/')
} finally {
setLoading(false)
}
setVoices(voicesRes.data)
const def = voicesRes.data.find(v => v.is_default)
if (def) setSelectedVoice(def.id)
} catch { navigate('/') }
finally { setLoading(false) }
}
load()
return () => clearInterval(timerRef.current)
}, [id])
// Timer countdown
useEffect(() => {
const startQuiz = async (mode, voice) => {
if (hasStarted.current) return
hasStarted.current = true
setSelectedVoice(voice)
setQuizMode(mode)
// Fetch full question data with study mode if needed
try {
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
setQuiz(quizRes.data)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
if (mode === 'exam' && quizRes.data.time_limit_minutes) {
const secs = quizRes.data.time_limit_minutes * 60
setTimeLeft(secs); setTotalTime(secs)
}
} catch { navigate('/') }
}
useEffect(() => {
if (timeLeft === null) return
if (timeLeft <= 0) {
handleSubmit(true)
return
}
if (timeLeft <= 0) { handleSubmit(true); return }
timerRef.current = setInterval(() => {
setTimeLeft(t => {
if (t <= 1) { clearInterval(timerRef.current); return 0 }
return t - 1
})
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
}, 1000)
return () => clearInterval(timerRef.current)
}, [timeLeft === null])
const setAnswer = (questionId, value) => {
setAnswers(prev => ({ ...prev, [questionId]: value }))
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
// Warn before navigating away mid-quiz
const safeNavigate = (targetIdx) => {
if (Object.keys(answers).length > 0 && attemptId) {
const confirmed = window.confirm(
'You have answered some questions.\n\n• OK — go back (progress saved so far)\n• Cancel — stay on current question'
)
if (!confirmed) return
}
setCurrentIdx(targetIdx)
}
const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return
if (!autoSubmit && Object.keys(answers).length === 0) {
if (!window.confirm('You haven\'t answered any questions. Submit anyway?')) return
}
clearInterval(timerRef.current)
setSubmitting(true)
try {
const submission = {
answers: Object.entries(answers).map(([qid, answer]) => ({
question_id: parseInt(qid),
user_answer: answer,
question_id: parseInt(qid), user_answer: answer,
})),
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
navigate(`/results/${attemptId}`, { state: { result: res.data } })
} catch (err) {
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
} finally {
setSubmitting(false)
}
} finally { setSubmitting(false) }
}, [attemptId, answers, submitting])
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null
if (!quizMode) return (
<div>
{isModerator && (
<div style={{ textAlign: 'right', marginBottom: 8 }}>
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div>
)}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
</div>
)
const isStudy = quizMode === 'study'
const questions = quiz.questions || []
const current = questions[currentIdx]
const isLearning = quiz.mode === 'learning'
const answeredCount = Object.keys(answers).length
const totalCount = questions.length
const isLast = currentIdx === totalCount - 1
return (
<div>
{/* Header */}
<div className="quiz-bottom">
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
<span style={{
background: isLearning ? '#d1fae5' : '#e0e7ff',
color: isLearning ? '#065f46' : '#3730a3',
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
}}>
{isLearning ? 'Learning Mode' : 'Timed Mode'}
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
</span>
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
</div>
</div>
{timeLeft !== null && (
<TimerDisplay seconds={timeLeft} total={totalTime} />
)}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{voices.length > 1 && (
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
disabled={ttsActive}
title={ttsActive ? 'Stop the current audio before changing voice' : ''}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.8rem', opacity: ttsActive ? 0.5 : 1 }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
</div>
</div>
{/* Progress bar */}
<div className="progress-bar" style={{ marginBottom: 20 }}>
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{/* Current question */}
{current && (
<div className="question-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<h3 style={{ flex: 1 }}>
Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}
</h3>
<TTSButton text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} />
<h3 style={{ flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
<TTSButton
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
voice={selectedVoice}
onActiveChange={setTtsActive}
/>
</div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{/* Question image */}
{current.image_path && (
<div style={{ margin: '12px 0' }}>
<img
src={`/uploads/${current.image_path}`}
alt="Question illustration"
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
onError={e => e.target.style.display = 'none'}
/>
onError={e => e.target.style.display = 'none'} />
</div>
)}
{/* Answer options */}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
<div className="options" style={{ marginTop: 12 }}>
{current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt
const isCorrect = isLearning && opt === current.correct_answer
const isWrong = isLearning && isSelected && opt !== current.correct_answer
const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt === current.correct_answer
const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)
return (
<div
key={i}
className={`option ${isSelected ? 'selected' : ''} ${isCorrect && isSelected ? 'correct' : ''} ${isWrong ? 'incorrect' : ''}`}
onClick={() => setAnswer(current.id, opt)}
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}
>
<input type="radio" name={`q-${current.id}`} checked={isSelected} onChange={() => setAnswer(current.id, opt)} />
<span>{opt}</span>
{isLearning && isCorrect && <span style={{ marginLeft: 'auto', color: '#22c55e', fontWeight: 600 }}> Correct</span>}
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
) : (
<input
type="text"
placeholder="Type your answer..."
<input type="text" placeholder="Type your answer..."
value={answers[current.id] || ''}
onChange={e => setAnswer(current.id, e.target.value)}
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }}
/>
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }} />
)}
{/* Learning mode: show explanation after answering */}
{isLearning && answers[current.id] && current.explanation && (
<div className="explanation" style={{ marginTop: 16 }}>
<strong>Explanation:</strong> {current.explanation}
</div>
)}
{/* Learning mode: show correct answer for fill blank */}
{isLearning && current.question_type === 'fill_blank' && answers[current.id] && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Answer:</strong> {current.correct_answer}
</div>
{/* Study mode: show explanation immediately after answering */}
{isStudy && answers[current.id] && (
<>
{current.explanation && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
<strong>Explanation:</strong> {current.explanation}
</div>
)}
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}
</div>
)}
</>
)}
</div>
)}
{/* Navigation */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
<button
className="btn btn-secondary"
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
<div className="quiz-nav">
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}
> Previous</button>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<div className="quiz-nav-numbers">
{questions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIdx(i)}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
}}
>{i + 1}</button>
<button key={q.id} onClick={() => safeNavigate(i)} style={{
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
}}>{i + 1}</button>
))}
</div>
{isLast ? (
<button
className="btn btn-primary"
onClick={() => handleSubmit(false)}
disabled={submitting}
>
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button
className="btn btn-primary"
onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}
>Next </button>
<button className="btn btn-primary" onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}>Next </button>
)}
</div>

View file

@ -1,10 +1,34 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <span>{text}</span>
return (
<span>
{text.slice(0, idx)}
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</span>
)
}
export default function QuizzesPage() {
const [quizzes, setQuizzes] = useState([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('all')
const [searchResults, setSearchResults] = useState(null)
const [searching, setSearching] = useState(false)
const debounceRef = useRef(null)
const { user } = useAuth()
const navigate = useNavigate()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
useEffect(() => {
api.get('/quizzes/').then(res => setQuizzes(res.data))
@ -12,32 +36,199 @@ export default function QuizzesPage() {
.finally(() => setLoading(false))
}, [])
useEffect(() => {
clearTimeout(debounceRef.current)
if (searchQuery.trim().length < 2) {
setSearchResults(null)
return
}
setSearching(true)
debounceRef.current = setTimeout(async () => {
try {
const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
setSearchResults(res.data)
} catch {
setSearchResults([])
} finally {
setSearching(false)
}
}, 350)
return () => clearTimeout(debounceRef.current)
}, [searchQuery, searchMode])
const deleteQuiz = async (e, quizId) => {
e.stopPropagation()
if (!confirm('Delete this quiz and all its attempts?')) return
try {
await api.delete(`/quizzes/${quizId}`)
setQuizzes(prev => prev.filter(q => q.id !== quizId))
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
} catch (err) {
alert(err.response?.data?.detail || 'Failed to delete quiz')
}
}
const isSearching = searchQuery.trim().length >= 2
const displayList = isSearching ? searchResults : null
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
return (
<div>
<div className="card">
<h2>Your Quizzes</h2>
{quizzes.length === 0 ? (
<div className="empty-state">
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
{/* Search bar */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '1rem' }}>🔍</span>
<input
type="text"
placeholder="Search quizzes or questions..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '9px 13px 9px 36px',
border: '1px solid var(--border)', borderRadius: 8,
fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)',
}}
/>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
<button key={val} onClick={() => setSearchMode(val)}
className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}>
{label}
</button>
))}
</div>
</div>
{isSearching && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{searching ? 'Searching...' : searchResults
? `${searchResults.length} result${searchResults.length !== 1 ? 's' : ''} for "${searchQuery}"`
: ''}
</div>
) : (
quizzes.map(quiz => (
<div className="section-item" key={quiz.id}>
<div>
<strong>{quiz.title}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{quiz.questions_count} questions | {new Date(quiz.created_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/quizzes/${quiz.id}`} className="btn btn-primary btn-sm">Take Quiz</Link>
</div>
</div>
))
)}
</div>
{/* Search results */}
{isSearching && searchResults !== null && (
searchResults.length === 0 ? (
<div className="card">
<div className="empty-state">No matches found for "{searchQuery}"</div>
</div>
) : (
<div>
{searchResults.map(result => (
<div key={result.quiz_id} className="card" style={{ marginBottom: 12, cursor: 'pointer' }}
onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: result.matching_questions.length > 0 ? 12 : 0 }}>
<div>
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 8 }}>
<span>{result.questions_count} questions</span>
{result.match_type === 'both' && <span style={{ color: '#7c3aed' }}> matched title + questions</span>}
{result.match_type === 'questions' && <span style={{ color: '#2563eb' }}> {result.matching_questions.length} matching question{result.matching_questions.length !== 1 ? 's' : ''}</span>}
{result.match_type === 'title' && <span style={{ color: '#059669' }}> title match</span>}
</div>
</div>
{isModerator && (
<button onClick={e => deleteQuiz(e, result.quiz_id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>
)}
</div>
{result.matching_questions.slice(0, 3).map((q, i) => (
<div key={q.id} style={{
padding: '8px 12px', background: 'var(--bg)', borderRadius: 6,
marginBottom: i < Math.min(result.matching_questions.length, 3) - 1 ? 6 : 0,
fontSize: '0.85rem', color: 'var(--text-muted)',
borderLeft: '3px solid var(--primary)',
}}>
<div style={{ marginBottom: q.options ? 4 : 0 }}>
<HighlightText text={q.question_text.slice(0, 160) + (q.question_text.length > 160 ? '…' : '')} query={searchQuery} />
</div>
{q.options && q.options.some(o => o.toLowerCase().includes(searchQuery.toLowerCase())) && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-subtle)', display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{q.options.filter(o => o.toLowerCase().includes(searchQuery.toLowerCase())).slice(0, 3).map((o, j) => (
<span key={j} style={{ background: '#fef08a', padding: '1px 6px', borderRadius: 4, color: '#78350f', fontSize: '0.78rem' }}>
<HighlightText text={o.slice(0, 80)} query={searchQuery} />
</span>
))}
</div>
)}
</div>
))}
{result.matching_questions.length > 3 && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 6, paddingLeft: 4 }}>
+{result.matching_questions.length - 3} more matching question{result.matching_questions.length - 3 !== 1 ? 's' : ''}
</div>
)}
</div>
))}
</div>
)
)}
{/* Normal quiz grid (hidden during active search) */}
{!isSearching && (
<>
<div className="card">
<h2>All Quizzes</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: 4 }}>
{quizzes.length} quiz{quizzes.length !== 1 ? 'zes' : ''} available
</p>
</div>
{quizzes.length === 0 ? (
<div className="card">
<div className="empty-state">
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16, marginTop: 8 }}>
{quizzes.map(quiz => (
<div key={quiz.id} onClick={() => navigate(`/quizzes/${quiz.id}`)}
style={{
background: 'var(--card-bg)', border: 'var(--card-border)',
borderRadius: 'var(--card-radius)', padding: '20px 18px',
cursor: 'pointer', transition: 'box-shadow 0.15s, transform 0.15s',
display: 'flex', flexDirection: 'column', gap: 10, position: 'relative',
boxShadow: 'var(--card-shadow)',
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}
>
{isModerator && (
<button onClick={e => deleteQuiz(e, quiz.id)} title="Delete quiz"
style={{ position: 'absolute', top: 10, right: 10, background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', lineHeight: 1, padding: 4, borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>
)}
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
{quiz.mode === 'learning' ? '📖' : '📝'}
</div>
<div style={{ paddingRight: isModerator ? 20 : 0 }}>
<div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', lineHeight: 1.3 }}>{quiz.title}</div>
</div>
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>{quiz.questions_count} question{quiz.questions_count !== 1 ? 's' : ''}</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: 20, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', color: quiz.mode === 'learning' ? '#065f46' : '#3730a3' }}>
{quiz.mode === 'learning' ? 'Learning' : 'Timed'}
</span>
{quiz.time_limit_minutes && <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{quiz.time_limit_minutes} min</span>}
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function RegisterPage() {
const [name, setName] = useState('')
@ -8,16 +9,22 @@ export default function RegisterPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register } = useAuth()
const navigate = useNavigate()
const [done, setDone] = useState(false)
const { loginWithToken } = useAuth()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await register(email, password, name)
navigate('/')
const res = await api.post('/auth/register', { email, password, name })
if (res.data.requires_verification) {
setDone(true)
} else {
// First user (auto-verified admin) log in immediately
await loginWithToken(res.data.access_token)
window.location.href = '/'
}
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed')
} finally {
@ -25,6 +32,27 @@ export default function RegisterPage() {
}
}
if (done) {
return (
<div className="auth-page">
<div className="auth-card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '3rem', marginBottom: 16 }}>📧</div>
<h1 style={{ marginBottom: 12 }}>Check your email</h1>
<p style={{ color: '#64748b', marginBottom: 8 }}>
We sent a verification link to <strong>{email}</strong>.
</p>
<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>
<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>
</div>
</div>
</div>
)
}
return (
<div className="auth-page">
<div className="auth-card">
@ -41,7 +69,7 @@ export default function RegisterPage() {
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Creating account...' : 'Sign Up'}

View file

@ -0,0 +1,73 @@
import { useState } from 'react'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
export default function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (password !== confirm) { setError('Passwords do not match'); return }
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 })
setSuccess(true)
setTimeout(() => navigate('/login'), 2500)
} catch (err) {
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🩺 PedQuiz</div>
</div>
<div className="card">
{success ? (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Password reset!</h2>
<p style={{ color: '#64748b' }}>Redirecting you to login...</p>
</div>
) : (
<>
<h2 style={{ marginBottom: 4 }}>Set new password</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>Choose a strong password for your account.</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>New Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="At least 8 characters" />
</div>
<div className="form-group">
<label>Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || !token}>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
{!token && <p style={{ color: '#dc2626', fontSize: '0.85rem', marginTop: 8 }}>Invalid or missing reset token.</p>}
</form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: '0.9rem' }}>
<Link to="/login" style={{ color: '#2563eb' }}>Back to login</Link>
</p>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -21,55 +21,146 @@ export default function ResultsPage() {
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
if (!result) return null
const scoreClass = result.percentage >= 70 ? 'good' : result.percentage >= 50 ? 'ok' : 'poor'
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 total = result.total_questions
return (
<div>
<div className="card">
<div style={{ maxWidth: 780, margin: '0 auto' }}>
{/* Score card */}
<div className="card" style={{ marginBottom: 24 }}>
<div className="score-display">
<div className={`score-value ${scoreClass}`}>{result.percentage}%</div>
<div style={{ fontSize: '1.1rem', color: '#64748b', marginTop: 8 }}>
{result.score} out of {result.total_questions} correct
<div className={`score-value ${scoreClass}`}>{pct}%</div>
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
{correct} of {total} correct
</div>
<div style={{ marginTop: 16, color: '#475569' }}>
{result.percentage >= 90 ? 'Excellent! You\'ve mastered this material.' :
result.percentage >= 70 ? 'Good job! A few more reviews and you\'ll nail it.' :
result.percentage >= 50 ? 'Getting there! Keep practicing.' :
'Don\'t worry — review the explanations below and try again!'}
<div style={{ marginTop: 10, fontSize: '0.95rem', color: 'var(--text-muted)' }}>
{pct >= 90 ? "Excellent — you've mastered this material." :
pct >= 75 ? 'Good work! Review any missed questions below.' :
pct >= 50 ? 'Getting there — study the explanations and retake.' :
'Review all explanations carefully before retaking.'}
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
{/* Score bar */}
<div style={{ maxWidth: 320, margin: '20px auto 24px' }}>
<div style={{ height: 8, background: 'var(--border)', borderRadius: 999, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 999, transition: 'width 0.6s ease',
width: `${pct}%`,
background: pct >= 75 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4, fontSize: '0.75rem', color: 'var(--text-subtle)' }}>
<span>0%</span><span style={{ color: '#d97706' }}>75%</span><span>100%</span>
</div>
</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>
</div>
</div>
</div>
<h2 style={{ marginBottom: 16 }}>Question Review</h2>
{result.answers?.map((ans, idx) => (
<div className="question-card" key={idx} style={{
borderLeftColor: ans.is_correct ? '#22c55e' : '#ef4444'
}}>
<h3>Q{idx + 1}. {ans.question_text}</h3>
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontWeight: 600, color: ans.is_correct ? '#22c55e' : '#ef4444' }}>
{ans.is_correct ? 'Correct' : 'Incorrect'}
{/* 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>
</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 style={{ fontSize: '0.9rem' }}>
<div><strong>Your answer:</strong> {ans.user_answer || '(no answer)'}</div>
{!ans.is_correct && (
<div style={{ color: '#22c55e' }}><strong>Correct answer:</strong> {ans.correct_answer}</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>
)}
{/* Fill-blank */}
{(!ans.options || ans.options.length === 0) && (
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
<div style={{ padding: '8px 14px', background: 'var(--wrong-bg)', borderRadius: 6, marginBottom: 6 }}>
<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>
{ans.explanation && (
<div className="explanation">
<strong>Explanation:</strong> {ans.explanation}
</div>
)}
</div>
))}
)
})}
</div>
)
}

View file

@ -0,0 +1,203 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import api from '../api/client'
function Section({ title, children }) {
return (
<div className="card" style={{ marginBottom: 16 }}>
<h2 style={{ marginBottom: 20 }}>{title}</h2>
{children}
</div>
)
}
function ProfileSection({ user }) {
const [name, setName] = useState(user?.name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError(''); setSuccess('')
if (newPassword && newPassword !== confirmPassword) return setError('Passwords do not match')
if (newPassword && newPassword.length < 8) return setError('Password must be at least 8 characters')
const payload = {}
if (name !== user.name) payload.name = name
if (newPassword) { payload.current_password = currentPassword; payload.new_password = newPassword }
if (!Object.keys(payload).length) return setError('No changes to save')
setLoading(true)
try {
await api.put('/auth/me', payload)
setSuccess('Saved successfully')
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
if (payload.name) setTimeout(() => window.location.reload(), 800)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save')
} finally { setLoading(false) }
}
return (
<Section title="Profile">
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 20 }}>
{user?.email} &nbsp;
<span style={{
fontSize: '0.72rem', fontWeight: 600, padding: '1px 8px', borderRadius: 10,
background: user?.role === 'admin' ? '#fee2e2' : user?.role === 'moderator' ? '#ede9fe' : '#dbeafe',
color: user?.role === 'admin' ? '#dc2626' : user?.role === 'moderator' ? '#7c3aed' : '#2563eb',
}}>{user?.role}</span>
</p>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<p style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 12 }}>
Change Password <span style={{ fontWeight: 400 }}>(leave blank to keep current)</span>
</p>
<div className="form-group">
<label>Current Password</label>
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
</div>
<div className="form-group">
<label>New Password</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="At least 8 characters" />
</div>
<div className="form-group">
<label>Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div>
<button className="btn btn-primary" type="submit" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button>
</form>
</Section>
)
}
function AppearanceSection() {
const { theme, setTheme } = useTheme()
const themes = [
{ value: 'default', label: 'Default', desc: 'Clean blue and white', icon: '🎨' },
{ value: 'markdown', label: 'Markdown', desc: 'GitHub-inspired monochrome', icon: '📝' },
]
return (
<Section title="Appearance">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{themes.map(t => (
<div key={t.value} onClick={() => setTheme(t.value)}
style={{
flex: 1, minWidth: 140, padding: '14px 16px', borderRadius: 10, cursor: 'pointer',
border: `2px solid ${theme === t.value ? 'var(--primary)' : 'var(--border)'}`,
background: theme === t.value ? 'var(--option-sel-bg)' : 'var(--card-bg)',
transition: 'border-color 0.15s',
}}>
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{t.icon}</div>
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: theme === t.value ? 'var(--primary)' : 'var(--text)' }}>{t.label}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{t.desc}</div>
</div>
))}
</div>
</Section>
)
}
function NextcloudSection() {
const [server, setServer] = useState(localStorage.getItem('nc_server') || 'https://cloud.danvics.com')
const [username, setUsername] = useState(localStorage.getItem('nc_username') || '')
const [password, setPassword] = useState(localStorage.getItem('nc_password') || '')
const [saved, setSaved] = useState(false)
const save = () => {
localStorage.setItem('nc_server', server)
localStorage.setItem('nc_username', username)
localStorage.setItem('nc_password', password)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const clear = () => {
localStorage.removeItem('nc_server')
localStorage.removeItem('nc_username')
localStorage.removeItem('nc_password')
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
}
return (
<Section title="Nextcloud Integration">
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
Connect your Nextcloud account to import PDFs directly when uploading.
</p>
{saved && <div className="alert alert-success">Nextcloud settings saved</div>}
<div className="form-group">
<label>Server URL</label>
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
</div>
<div className="form-group">
<label>Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
</div>
<div className="form-group">
<label>App Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save}>Save</button>
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
Credentials are stored locally in your browser. Use an App Password, not your account password.
</p>
</Section>
)
}
function AdminSection() {
return (
<Section title="Administration">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
{[
{ to: '/admin', icon: '👥', label: 'User Management', desc: 'Manage accounts and roles' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
].map(item => (
<Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
<div style={{
padding: '16px', borderRadius: 10, border: '1px solid var(--border)',
background: 'var(--bg)', transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{item.icon}</div>
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--text)' }}>{item.label}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{item.desc}</div>
</div>
</Link>
))}
</div>
</Section>
)
}
export default function SettingsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
</div>
<ProfileSection user={user} />
<AppearanceSection />
<NextcloudSection />
{(isAdmin || isModerator) && <AdminSection />}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import api from '../api/client'
export default function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState('verifying') // verifying | success | error
const [message, setMessage] = useState('')
useEffect(() => {
if (!token) { setStatus('error'); setMessage('No verification token provided.'); return }
api.get(`/auth/verify-email?token=${token}`)
.then(res => { setStatus('success'); setMessage(res.data.message) })
.catch(err => { setStatus('error'); setMessage(err.response?.data?.detail || 'Verification failed.') })
}, [token])
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div className="card" style={{ maxWidth: 420, width: '100%', textAlign: 'center' }}>
{status === 'verifying' && (
<><div className="spinner" style={{ margin: '0 auto 16px' }}></div><p>Verifying your email...</p></>
)}
{status === 'success' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 12 }}></div>
<h2 style={{ color: '#166534', marginBottom: 8 }}>Email Verified!</h2>
<p style={{ color: '#64748b', marginBottom: 24 }}>{message}</p>
<Link to="/login" className="btn btn-primary">Log In</Link>
</>
)}
{status === 'error' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 12 }}></div>
<h2 style={{ color: '#dc2626', marginBottom: 8 }}>Verification Failed</h2>
<p style={{ color: '#64748b', marginBottom: 24 }}>{message}</p>
<Link to="/login" className="btn btn-secondary">Back to Login</Link>
</>
)}
</div>
</div>
)
}