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:
parent
b876f13fac
commit
47ba213ae3
54 changed files with 3572 additions and 882 deletions
172
README.md
Normal file
172
README.md
Normal 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
|
||||||
|
|
@ -13,6 +13,17 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
LITELLM_MODEL: str = "gpt-4o-mini"
|
LITELLM_MODEL: str = "gpt-4o-mini"
|
||||||
LITELLM_API_KEY: str = ""
|
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"
|
CHROMA_PERSIST_DIR: str = "./chroma_data"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||||
def seed_admin():
|
def seed_admin():
|
||||||
"""Create default admin user if none exists."""
|
"""Create default admin user if none exists."""
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.email_verification import EmailVerification
|
||||||
|
from app.models.password_reset import PasswordReset
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
admin_exists = db.query(User).filter(User.role == "admin").first()
|
admin_exists = db.query(User).filter(User.role == "admin").first()
|
||||||
|
|
@ -26,7 +28,29 @@ def seed_admin():
|
||||||
role="admin",
|
role="admin",
|
||||||
)
|
)
|
||||||
db.add(admin_user)
|
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()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -38,57 +62,123 @@ def seed_default_models():
|
||||||
try:
|
try:
|
||||||
if db.query(AIModelConfig).count() == 0:
|
if db.query(AIModelConfig).count() == 0:
|
||||||
defaults = [
|
defaults = [
|
||||||
AIModelConfig(
|
AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True),
|
||||||
name="GPT-4o Mini (Extraction)",
|
AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False),
|
||||||
model_id="gpt-4o-mini",
|
AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False),
|
||||||
task="extraction",
|
AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
|
||||||
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,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
db.add_all(defaults)
|
db.add_all(defaults)
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
setup_pgvector()
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||||
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
|
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
|
||||||
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
||||||
seed_admin()
|
seed_admin()
|
||||||
seed_default_models()
|
seed_default_models()
|
||||||
|
backfill_embeddings()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
|
@ -96,15 +186,15 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="PDF Quiz Generator",
|
title="PedQuiz",
|
||||||
description="Convert PDF files into interactive quizzes with AI",
|
description="Pediatric Knowledge Quiz Platform",
|
||||||
version="2.0.0",
|
version="2.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"],
|
allow_origins=["https://quiz.danvics.com"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ class QuizAttempt(Base):
|
||||||
__tablename__ = "quiz_attempts"
|
__tablename__ = "quiz_attempts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
score = Column(Integer, default=0)
|
score = Column(Integer, default=0)
|
||||||
total_questions = Column(Integer, default=0)
|
total_questions = Column(Integer, default=0)
|
||||||
started_at = Column(DateTime, default=datetime.utcnow)
|
started_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
@ -26,8 +26,8 @@ class AttemptAnswer(Base):
|
||||||
__tablename__ = "attempt_answers"
|
__tablename__ = "attempt_answers"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
|
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False)
|
||||||
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
||||||
user_answer = Column(String, nullable=False)
|
user_answer = Column(String, nullable=False)
|
||||||
is_correct = Column(Boolean, default=False)
|
is_correct = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
|
||||||
14
backend/app/models/email_verification.py
Normal file
14
backend/app/models/email_verification.py
Normal 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)
|
||||||
14
backend/app/models/password_reset.py
Normal file
14
backend/app/models/password_reset.py
Normal 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)
|
||||||
|
|
@ -10,7 +10,7 @@ class PDFDocument(Base):
|
||||||
__tablename__ = "pdf_documents"
|
__tablename__ = "pdf_documents"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
filename = Column(String, nullable=False)
|
||||||
original_filename = Column(String, nullable=False)
|
original_filename = Column(String, nullable=False)
|
||||||
total_pages = Column(Integer, nullable=True)
|
total_pages = Column(Integer, nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,13 +10,14 @@ class Question(Base):
|
||||||
__tablename__ = "questions"
|
__tablename__ = "questions"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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_text = Column(Text, nullable=False)
|
||||||
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
|
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
|
||||||
options = Column(JSON, nullable=True) # list of strings for mcq
|
options = Column(JSON, nullable=True) # list of strings for mcq
|
||||||
correct_answer = Column(String, nullable=False)
|
correct_answer = Column(String, nullable=False)
|
||||||
explanation = Column(Text, nullable=True)
|
explanation = Column(Text, nullable=True)
|
||||||
page_reference = Column(Integer, 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")
|
quiz = relationship("Quiz", back_populates="questions")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
@ -16,9 +16,11 @@ class Quiz(Base):
|
||||||
questions_count = Column(Integer, default=0)
|
questions_count = Column(Integer, default=0)
|
||||||
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
|
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
|
||||||
mode = Column(String, default="timed") # timed, learning
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
section = relationship("Section", back_populates="quizzes")
|
section = relationship("Section", back_populates="quizzes")
|
||||||
user = relationship("User", back_populates="quizzes")
|
user = relationship("User", back_populates="quizzes")
|
||||||
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
|
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
|
||||||
attempts = relationship("QuizAttempt", 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")
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ class ReminderSchedule(Base):
|
||||||
__tablename__ = "reminder_schedules"
|
__tablename__ = "reminder_schedules"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
||||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
||||||
next_reminder_at = Column(DateTime, nullable=False)
|
next_reminder_at = Column(DateTime, nullable=False)
|
||||||
interval_days = Column(Integer, default=1)
|
interval_days = Column(Integer, default=1)
|
||||||
performance_score = Column(Float, default=0.0)
|
performance_score = Column(Float, default=0.0)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class Section(Base):
|
||||||
__tablename__ = "sections"
|
__tablename__ = "sections"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
name = Column(String, nullable=False)
|
||||||
start_page = Column(Integer, nullable=False)
|
start_page = Column(Integer, nullable=False)
|
||||||
end_page = Column(Integer, nullable=False)
|
end_page = Column(Integer, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.ai_model_config import AIModelConfig
|
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.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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -43,8 +45,88 @@ def update_user_role(
|
||||||
return user
|
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 ---
|
# --- 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])
|
@router.get("/models", response_model=list[AIModelConfigResponse])
|
||||||
def list_models(
|
def list_models(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -62,7 +144,6 @@ def create_model(
|
||||||
if data.task not in ("extraction", "tts", "general"):
|
if data.task not in ("extraction", "tts", "general"):
|
||||||
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or 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:
|
if data.is_default:
|
||||||
db.query(AIModelConfig).filter(
|
db.query(AIModelConfig).filter(
|
||||||
AIModelConfig.task == data.task,
|
AIModelConfig.task == data.task,
|
||||||
|
|
@ -89,7 +170,6 @@ def update_model(
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
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)
|
task = update_data.get("task", model.task)
|
||||||
if update_data.get("is_default"):
|
if update_data.get("is_default"):
|
||||||
db.query(AIModelConfig).filter(
|
db.query(AIModelConfig).filter(
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ def start_attempt(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||||
|
|
||||||
|
|
@ -74,33 +74,37 @@ def submit_attempt(
|
||||||
}
|
}
|
||||||
|
|
||||||
score = 0
|
score = 0
|
||||||
answer_details = []
|
submitted = {ans.question_id: ans.user_answer for ans in submission.answers}
|
||||||
|
|
||||||
|
# Save submitted answers
|
||||||
for ans in submission.answers:
|
for ans in submission.answers:
|
||||||
question = questions.get(ans.question_id)
|
question = questions.get(ans.question_id)
|
||||||
if not question:
|
if not question:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
|
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
|
||||||
if is_correct:
|
if is_correct:
|
||||||
score += 1
|
score += 1
|
||||||
|
db.add(AttemptAnswer(
|
||||||
attempt_answer = AttemptAnswer(
|
|
||||||
attempt_id=attempt_id,
|
attempt_id=attempt_id,
|
||||||
question_id=ans.question_id,
|
question_id=ans.question_id,
|
||||||
user_answer=ans.user_answer,
|
user_answer=ans.user_answer,
|
||||||
is_correct=is_correct,
|
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(
|
answer_details.append(AnswerDetail(
|
||||||
question_id=question.id,
|
question_id=q.id,
|
||||||
question_text=question.question_text,
|
question_text=q.question_text,
|
||||||
question_type=question.question_type,
|
question_type=q.question_type,
|
||||||
user_answer=ans.user_answer,
|
options=q.options,
|
||||||
correct_answer=question.correct_answer,
|
user_answer=user_answer,
|
||||||
|
correct_answer=q.correct_answer,
|
||||||
is_correct=is_correct,
|
is_correct=is_correct,
|
||||||
explanation=question.explanation,
|
explanation=q.explanation,
|
||||||
))
|
))
|
||||||
|
|
||||||
attempt.score = score
|
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)
|
@router.get("/stats/dashboard", response_model=DashboardStats)
|
||||||
def get_dashboard_stats(
|
def get_dashboard_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -210,17 +247,20 @@ def get_attempt(
|
||||||
if not attempt:
|
if not attempt:
|
||||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
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 = []
|
answer_details = []
|
||||||
for ans in attempt.answers:
|
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all():
|
||||||
question = ans.question
|
ans = submitted_map.get(q.id)
|
||||||
answer_details.append(AnswerDetail(
|
answer_details.append(AnswerDetail(
|
||||||
question_id=question.id,
|
question_id=q.id,
|
||||||
question_text=question.question_text,
|
question_text=q.question_text,
|
||||||
question_type=question.question_type,
|
question_type=q.question_type,
|
||||||
user_answer=ans.user_answer,
|
options=q.options,
|
||||||
correct_answer=question.correct_answer,
|
user_answer=ans.user_answer if ans else "",
|
||||||
is_correct=ans.is_correct,
|
correct_answer=q.correct_answer,
|
||||||
explanation=question.explanation,
|
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
|
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||||
|
|
|
||||||
|
|
@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest
|
from app.models.email_verification import EmailVerification
|
||||||
from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user
|
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()
|
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 _check_login_rate_limit(client_ip: str):
|
||||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
now = datetime.utcnow()
|
||||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
window = now - timedelta(minutes=15)
|
||||||
if existing:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail="Email already registered",
|
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(
|
user = User(
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
hashed_password=get_password_hash(user_data.password),
|
hashed_password=get_password_hash(user_data.password),
|
||||||
name=user_data.name,
|
name=user_data.name,
|
||||||
|
role="admin" if is_first_user else "user",
|
||||||
)
|
)
|
||||||
db.add(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.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": user.email})
|
if is_first_user:
|
||||||
return Token(access_token=access_token)
|
# 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)
|
@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()
|
user = db.query(User).filter(User.email == login_data.email).first()
|
||||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=403,
|
||||||
detail="Invalid email or password",
|
detail="Email not verified. Please check your inbox and verify your email before logging in.",
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": user.email})
|
access_token = create_access_token(data={"sub": user.email})
|
||||||
return Token(access_token=access_token)
|
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)
|
@router.get("/me", response_model=UserResponse)
|
||||||
def get_me(current_user: User = Depends(get_current_user)):
|
def get_me(current_user: User = Depends(get_current_user)):
|
||||||
return 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
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ def upload_document(
|
||||||
|
|
||||||
# Save file to disk streaming (handles large files)
|
# Save file to disk streaming (handles large files)
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
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)
|
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
|
||||||
|
|
||||||
with open(file_path, "wb") as buffer:
|
with open(file_path, "wb") as buffer:
|
||||||
|
|
@ -140,6 +140,10 @@ def create_section(
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
if doc.status != "ready":
|
if doc.status != "ready":
|
||||||
raise HTTPException(status_code=400, detail="Document is not ready yet")
|
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:
|
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)")
|
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.quiz import Quiz
|
from app.models.quiz import Quiz
|
||||||
|
from app.models.question import Question as QuestionModel
|
||||||
from app.models.section import Section
|
from app.models.section import Section
|
||||||
from app.models.attempt import QuizAttempt
|
from app.models.attempt import QuizAttempt
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
@ -37,6 +40,7 @@ def create_quiz(
|
||||||
title=quiz_data.title,
|
title=quiz_data.title,
|
||||||
mode=quiz_data.mode,
|
mode=quiz_data.mode,
|
||||||
time_limit_minutes=quiz_data.time_limit_minutes,
|
time_limit_minutes=quiz_data.time_limit_minutes,
|
||||||
|
model_id=quiz_data.model_id,
|
||||||
)
|
)
|
||||||
return quiz
|
return quiz
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -45,6 +49,119 @@ def create_quiz(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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])
|
@router.get("/", response_model=list[QuizResponse])
|
||||||
def list_quizzes(
|
def list_quizzes(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -61,19 +178,65 @@ def list_quizzes(
|
||||||
@router.get("/{quiz_id}")
|
@router.get("/{quiz_id}")
|
||||||
def get_quiz(
|
def get_quiz(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
|
study: bool = Query(False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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()
|
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||||
if not quiz:
|
if not quiz:
|
||||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
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 QuizLearningDetail.model_validate(quiz)
|
||||||
return QuizDetail.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)
|
@router.get("/{quiz_id}/review", response_model=QuizReview)
|
||||||
def review_quiz(
|
def review_quiz(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
|
|
@ -96,6 +259,96 @@ def review_quiz(
|
||||||
return 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)
|
@router.delete("/{quiz_id}", status_code=204)
|
||||||
def delete_quiz(
|
def delete_quiz(
|
||||||
quiz_id: int,
|
quiz_id: int,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.ai_model_config import AIModelConfig
|
||||||
from app.services import ai_service
|
from app.services import ai_service
|
||||||
from app.utils.auth import get_current_user
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
|
|
@ -13,6 +15,22 @@ router = APIRouter()
|
||||||
|
|
||||||
class TTSRequest(BaseModel):
|
class TTSRequest(BaseModel):
|
||||||
text: str
|
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")
|
@router.post("/speak")
|
||||||
|
|
@ -21,15 +39,24 @@ def text_to_speech(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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():
|
if not request.text.strip():
|
||||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||||
|
|
||||||
# Limit text length
|
|
||||||
text = request.text[:2000]
|
text = request.text[:2000]
|
||||||
|
|
||||||
# Get TTS model config
|
if request.voice:
|
||||||
model_id, api_key = ai_service.get_model_for_task(db, "tts")
|
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)
|
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
|
||||||
if audio is None:
|
if audio is None:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class AIModelConfigCreate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AIModelConfigResponse(BaseModel):
|
class AIModelConfigResponse(BaseModel):
|
||||||
model_config = {"protected_namespaces": ()}
|
model_config = {"protected_namespaces": (), "from_attributes": True}
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
model_id: str
|
model_id: str
|
||||||
|
|
@ -23,9 +23,6 @@ class AIModelConfigResponse(BaseModel):
|
||||||
is_default: bool
|
is_default: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class AIModelConfigUpdate(BaseModel):
|
class AIModelConfigUpdate(BaseModel):
|
||||||
model_config = {"protected_namespaces": ()}
|
model_config = {"protected_namespaces": ()}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class AnswerDetail(BaseModel):
|
||||||
question_id: int
|
question_id: int
|
||||||
question_text: str
|
question_text: str
|
||||||
question_type: str
|
question_type: str
|
||||||
|
options: list[str] | None = None
|
||||||
user_answer: str
|
user_answer: str
|
||||||
correct_answer: str
|
correct_answer: str
|
||||||
is_correct: bool
|
is_correct: bool
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,19 @@ class LoginRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateRole(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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class QuizCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
mode: str = "timed" # timed, learning
|
mode: str = "timed" # timed, learning
|
||||||
time_limit_minutes: int | None = None
|
time_limit_minutes: int | None = None
|
||||||
|
model_id: str | None = None # override extraction model
|
||||||
|
|
||||||
|
|
||||||
class QuestionResponse(BaseModel):
|
class QuestionResponse(BaseModel):
|
||||||
|
|
@ -35,6 +36,7 @@ class QuizResponse(BaseModel):
|
||||||
questions_count: int
|
questions_count: int
|
||||||
mode: str
|
mode: str
|
||||||
time_limit_minutes: int | None
|
time_limit_minutes: int | None
|
||||||
|
skipped_questions: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
|
||||||
|
|
@ -7,28 +7,46 @@ from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
EXTRACTION_PROMPT = """You are extracting questions from a PREP (Pediatric Review and Education Program) exam PDF.
|
||||||
- "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}
|
|
||||||
|
|
||||||
Important:
|
These PDFs follow a strict format:
|
||||||
- Extract ALL questions found in the content — do not skip any
|
1. A numbered question with a clinical vignette (patient scenario)
|
||||||
- Preserve the original wording exactly
|
2. Five answer options labeled A, B, C, D, E
|
||||||
- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be
|
3. A line "Correct Answer: X" where X is the letter of the correct option
|
||||||
- If no clear correct answer is marked, use the best answer based on the explanation
|
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 from page(s) {page_info}:
|
||||||
{content}
|
{content}"""
|
||||||
|
|
||||||
Return ONLY valid JSON. No markdown formatting."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
|
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",
|
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
|
use_key = api_key or settings.LITELLM_API_KEY
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
@ -83,16 +101,14 @@ def extract_questions(
|
||||||
}
|
}
|
||||||
if use_key:
|
if use_key:
|
||||||
kwargs["api_key"] = use_key
|
kwargs["api_key"] = use_key
|
||||||
|
if settings.LITELLM_API_BASE:
|
||||||
|
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||||
|
|
||||||
# Try JSON mode if supported
|
# Don't force JSON mode — let the model respond naturally and we parse it
|
||||||
try:
|
response = litellm.completion(**kwargs)
|
||||||
kwargs["response_format"] = {"type": "json_object"}
|
|
||||||
response = litellm.completion(**kwargs)
|
|
||||||
except Exception:
|
|
||||||
kwargs.pop("response_format", None)
|
|
||||||
response = litellm.completion(**kwargs)
|
|
||||||
|
|
||||||
response_text = response.choices[0].message.content
|
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
|
# Try to parse JSON, handle markdown code blocks
|
||||||
text = response_text.strip()
|
text = response_text.strip()
|
||||||
|
|
@ -103,13 +119,33 @@ def extract_questions(
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
|
|
||||||
data = json.loads(text)
|
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")
|
raise ValueError("Response is not a list of questions")
|
||||||
|
|
||||||
validated = []
|
validated = []
|
||||||
|
skipped = []
|
||||||
for q in questions:
|
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
|
continue
|
||||||
qtype = q.get("question_type", "mcq")
|
qtype = q.get("question_type", "mcq")
|
||||||
if qtype not in ("mcq", "true_false", "fill_blank"):
|
if qtype not in ("mcq", "true_false", "fill_blank"):
|
||||||
|
|
@ -118,11 +154,16 @@ def extract_questions(
|
||||||
"question_text": q["question_text"],
|
"question_text": q["question_text"],
|
||||||
"question_type": qtype,
|
"question_type": qtype,
|
||||||
"options": q.get("options"),
|
"options": q.get("options"),
|
||||||
"correct_answer": q["correct_answer"],
|
"correct_answer": correct,
|
||||||
"explanation": q.get("explanation", ""),
|
"explanation": q.get("explanation", ""),
|
||||||
"page_reference": q.get("page_reference"),
|
"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:
|
if validated:
|
||||||
return validated
|
return validated
|
||||||
|
|
||||||
|
|
@ -130,7 +171,7 @@ def extract_questions(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = 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}")
|
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,
|
model_id: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Generate TTS audio using LiteLLM (supports Google Vertex, OpenAI, etc.)."""
|
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
|
||||||
use_model = model_id or "vertex_ai/google/cloud-tts"
|
|
||||||
use_key = api_key or settings.LITELLM_API_KEY
|
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:
|
try:
|
||||||
# LiteLLM speech endpoint
|
resp = httpx.post(
|
||||||
response = litellm.speech(
|
f"{base}/v1/audio/speech",
|
||||||
model=use_model,
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||||
input=text,
|
json={"model": clean_model, "input": text, "voice": oai_voice},
|
||||||
api_key=use_key if use_key else None,
|
timeout=60,
|
||||||
)
|
)
|
||||||
# Response is audio bytes
|
resp.raise_for_status()
|
||||||
if hasattr(response, "content"):
|
return resp.content
|
||||||
return response.content
|
|
||||||
if hasattr(response, "read"):
|
|
||||||
return response.read()
|
|
||||||
return bytes(response)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TTS generation failed: {e}")
|
logger.error(f"OpenAI TTS failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
|
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
|
||||||
|
|
||||||
|
|
@ -12,6 +14,7 @@ def get_mail_config() -> ConnectionConfig:
|
||||||
MAIL_USERNAME=settings.MAIL_USERNAME,
|
MAIL_USERNAME=settings.MAIL_USERNAME,
|
||||||
MAIL_PASSWORD=settings.MAIL_PASSWORD,
|
MAIL_PASSWORD=settings.MAIL_PASSWORD,
|
||||||
MAIL_FROM=settings.MAIL_FROM,
|
MAIL_FROM=settings.MAIL_FROM,
|
||||||
|
MAIL_FROM_NAME="PedQuiz",
|
||||||
MAIL_PORT=settings.MAIL_PORT,
|
MAIL_PORT=settings.MAIL_PORT,
|
||||||
MAIL_SERVER=settings.MAIL_SERVER,
|
MAIL_SERVER=settings.MAIL_SERVER,
|
||||||
MAIL_STARTTLS=settings.MAIL_STARTTLS,
|
MAIL_STARTTLS=settings.MAIL_STARTTLS,
|
||||||
|
|
@ -20,45 +23,152 @@ def get_mail_config() -> ConnectionConfig:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_reminder_email(
|
def _render(md: str) -> str:
|
||||||
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>
|
|
||||||
"""
|
"""
|
||||||
|
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(
|
if s.startswith("# "):
|
||||||
subject=f"Quiz Reminder: {quiz_title}",
|
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>')
|
||||||
recipients=[email],
|
elif s.startswith("## "):
|
||||||
body=html,
|
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>')
|
||||||
subtype=MessageType.html,
|
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:
|
try:
|
||||||
conf = get_mail_config()
|
fm = FastMail(get_mail_config())
|
||||||
fm = FastMail(conf)
|
await fm.send_message(MessageSchema(subject=subject, recipients=[to_email], body=html, subtype=MessageType.html))
|
||||||
await fm.send_message(message)
|
logger.info(f"Email sent → {to_email}: {subject}")
|
||||||
logger.info(f"Reminder sent to {email} for quiz '{quiz_title}'")
|
|
||||||
except Exception as e:
|
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))
|
||||||
|
|
|
||||||
86
backend/app/services/embedding_service.py
Normal file
86
backend/app/services/embedding_service.py
Normal 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
|
||||||
|
|
@ -8,7 +8,7 @@ from app.models.section import Section
|
||||||
from app.models.pdf_document import PDFDocument
|
from app.models.pdf_document import PDFDocument
|
||||||
from app.models.quiz import Quiz
|
from app.models.quiz import Quiz
|
||||||
from app.models.question import Question
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ def create_quiz_from_section(
|
||||||
title: str,
|
title: str,
|
||||||
mode: str = "timed",
|
mode: str = "timed",
|
||||||
time_limit_minutes: int | None = None,
|
time_limit_minutes: int | None = None,
|
||||||
|
model_id: str | None = None,
|
||||||
) -> Quiz:
|
) -> Quiz:
|
||||||
"""Extract questions from a section's page range using AI."""
|
"""Extract questions from a section's page range using AI."""
|
||||||
section = db.query(Section).filter(Section.id == section_id).first()
|
section = db.query(Section).filter(Section.id == section_id).first()
|
||||||
|
|
@ -40,8 +41,13 @@ def create_quiz_from_section(
|
||||||
if not content:
|
if not content:
|
||||||
raise ValueError("No content found for this section's page range")
|
raise ValueError("No content found for this section's page range")
|
||||||
|
|
||||||
# Get configured model
|
# Get configured model (use override if provided)
|
||||||
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
|
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)
|
# Extract questions via AI (not generate — questions already exist in PDF)
|
||||||
page_info = f"{section.start_page}-{section.end_page}"
|
page_info = f"{section.start_page}-{section.end_page}"
|
||||||
|
|
@ -63,20 +69,28 @@ def create_quiz_from_section(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Image extraction failed: {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
|
# Create quiz
|
||||||
quiz = Quiz(
|
quiz = Quiz(
|
||||||
section_id=section_id,
|
section_id=section_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
title=title,
|
||||||
questions_count=len(question_data),
|
questions_count=len(valid_questions),
|
||||||
mode=mode,
|
mode=mode,
|
||||||
time_limit_minutes=time_limit_minutes,
|
time_limit_minutes=time_limit_minutes,
|
||||||
|
skipped_questions=json.dumps(skipped) if skipped else None,
|
||||||
)
|
)
|
||||||
db.add(quiz)
|
db.add(quiz)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Create question records, associating images where possible
|
# Create question records, associating images where possible
|
||||||
for q in question_data:
|
for q in valid_questions:
|
||||||
page_ref = q.get("page_reference")
|
page_ref = q.get("page_reference")
|
||||||
image_path = None
|
image_path = None
|
||||||
|
|
||||||
|
|
@ -98,6 +112,11 @@ def create_quiz_from_section(
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
)
|
)
|
||||||
db.add(question)
|
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.commit()
|
||||||
db.refresh(quiz)
|
db.refresh(quiz)
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ def update_reminder_schedule(
|
||||||
current_idx = i
|
current_idx = i
|
||||||
break
|
break
|
||||||
|
|
||||||
if score_percentage < 70:
|
if score_percentage < 75:
|
||||||
# Poor performance: reset to shortest interval
|
# Below threshold: reset to shortest interval
|
||||||
new_idx = 0
|
new_idx = 0
|
||||||
elif score_percentage < 90:
|
elif score_percentage < 90:
|
||||||
# Decent performance: advance one step
|
# Decent performance: advance one step
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,28 @@
|
||||||
import chromadb
|
import chromadb
|
||||||
|
from chromadb import EmbeddingFunction, Embeddings
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
_client = None
|
_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:
|
def get_client() -> chromadb.PersistentClient:
|
||||||
global _client
|
global _client
|
||||||
if _client is None:
|
if _client is None:
|
||||||
|
|
@ -14,7 +32,13 @@ def get_client() -> chromadb.PersistentClient:
|
||||||
|
|
||||||
def get_or_create_collection(document_id: int):
|
def get_or_create_collection(document_id: int):
|
||||||
client = get_client()
|
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):
|
def delete_collection(document_id: int):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ celery_app = Celery(
|
||||||
"quiz_tasks",
|
"quiz_tasks",
|
||||||
broker=settings.REDIS_URL,
|
broker=settings.REDIS_URL,
|
||||||
backend=settings.REDIS_URL,
|
backend=settings.REDIS_URL,
|
||||||
|
include=["app.tasks.pdf_tasks"],
|
||||||
)
|
)
|
||||||
celery_app.conf.task_serializer = "json"
|
celery_app.conf.task_serializer = "json"
|
||||||
celery_app.conf.result_serializer = "json"
|
celery_app.conf.result_serializer = "json"
|
||||||
|
|
|
||||||
100
backend/manage.py
Normal file
100
backend/manage.py
Normal 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()
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
fastapi==0.109.2
|
fastapi==0.109.2
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
uvicorn[standard]==0.27.1
|
uvicorn[standard]==0.27.1
|
||||||
sqlalchemy==2.0.27
|
sqlalchemy==2.0.27
|
||||||
alembic==1.13.1
|
alembic==1.13.1
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
pydantic[email]==2.6.1
|
pydantic[email]==2.6.1
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
|
|
@ -17,3 +19,5 @@ apscheduler==3.10.4
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
boto3==1.34.69
|
||||||
|
pgvector==0.3.6
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,41 @@
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
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:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "127.0.0.1:8081:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
- chroma_data:/app/chroma_data
|
- chroma_data:/app/chroma_data
|
||||||
- sqlite_data:/app/data
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
|
|
@ -33,11 +46,11 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
- chroma_data:/app/chroma_data
|
- chroma_data:/app/chroma_data
|
||||||
- sqlite_data:/app/data
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
|
@ -49,5 +62,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
chroma_data:
|
chroma_data:
|
||||||
sqlite_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ server {
|
||||||
|
|
||||||
# API proxy to backend
|
# API proxy to backend
|
||||||
location /api/ {
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
import Navbar from './components/Navbar'
|
import Navbar from './components/Navbar'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import RegisterPage from './pages/RegisterPage'
|
import RegisterPage from './pages/RegisterPage'
|
||||||
|
|
@ -10,6 +11,12 @@ import QuizPage from './pages/QuizPage'
|
||||||
import QuizzesPage from './pages/QuizzesPage'
|
import QuizzesPage from './pages/QuizzesPage'
|
||||||
import ResultsPage from './pages/ResultsPage'
|
import ResultsPage from './pages/ResultsPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
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 }) {
|
function ProtectedRoute({ children, requireModerator = false }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
|
@ -19,6 +26,16 @@ function ProtectedRoute({ children, requireModerator = false }) {
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="site-footer">
|
||||||
|
<div className="container">
|
||||||
|
© {new Date().getFullYear()} PedQuiz
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
||||||
|
|
@ -30,6 +47,9 @@ function AppRoutes() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
|
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
|
||||||
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
|
<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="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
|
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
|
||||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></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="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
|
||||||
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
|
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
|
||||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></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>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -46,9 +70,11 @@ function AppRoutes() {
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<AppRoutes />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
frontend/src/components/LineChart.jsx
Normal file
71
frontend/src/components/LineChart.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,25 +8,13 @@ export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className="navbar">
|
<div className="navbar">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Link to="/" className="logo">PDF Quiz</Link>
|
<Link to="/" className="logo">🩺 PedQuiz</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/">Dashboard</Link>
|
<Link to="/">Dashboard</Link>
|
||||||
<Link to="/quizzes">Quizzes</Link>
|
<Link to="/quizzes">Quizzes</Link>
|
||||||
{isModerator && <Link to="/upload">Upload PDF</Link>}
|
{isModerator && <Link to="/upload">Upload PDF</Link>}
|
||||||
{user.role === 'admin' && <Link to="/admin" style={{ color: '#fbbf24' }}>Admin</Link>}
|
<Link to="/settings" title="Settings">⚙ Settings</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>
|
|
||||||
<button onClick={logout}>Logout</button>
|
<button onClick={logout}>Logout</button>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,8 @@ export function AuthProvider({ children }) {
|
||||||
return me.data
|
return me.data
|
||||||
}
|
}
|
||||||
|
|
||||||
const register = async (email, password, name) => {
|
const loginWithToken = async (token) => {
|
||||||
const res = await api.post('/auth/register', { email, password, name })
|
localStorage.setItem('token', token)
|
||||||
localStorage.setItem('token', res.data.access_token)
|
|
||||||
const me = await api.get('/auth/me')
|
const me = await api.get('/auth/me')
|
||||||
setUser(me.data)
|
setUser(me.data)
|
||||||
return me.data
|
return me.data
|
||||||
|
|
@ -41,7 +40,7 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, loginWithToken, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
22
frontend/src/context/ThemeContext.jsx
Normal file
22
frontend/src/context/ThemeContext.jsx
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,418 +1,311 @@
|
||||||
* {
|
/* ── CSS Variables (themes) ─────────────────────────────────── */
|
||||||
margin: 0;
|
:root {
|
||||||
padding: 0;
|
--bg: #f1f5f9;
|
||||||
box-sizing: border-box;
|
--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 {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
background: #f8fafc;
|
background: var(--bg);
|
||||||
color: #1e293b;
|
color: var(--text);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
transition: background 0.25s, color 0.25s;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
/* ── Layout ─────────────────────────────────────────────────── */
|
||||||
max-width: 1100px;
|
.container { max-width: 1100px; margin: 0 auto; padding: 0 20px; }
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation */
|
/* ── Navbar ─────────────────────────────────────────────────── */
|
||||||
.navbar {
|
.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 12px 0; margin-bottom: 28px; }
|
||||||
background: #1e293b;
|
.navbar .container { display: flex; justify-content: space-between; align-items: center; }
|
||||||
color: white;
|
.navbar .logo { font-size: 1.2rem; font-weight: 700; color: #60a5fa; text-decoration: none; }
|
||||||
padding: 12px 0;
|
[data-theme="minimal"] .navbar .logo { color: #a1a1aa; }
|
||||||
margin-bottom: 24px;
|
.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 {
|
/* ── Cards ──────────────────────────────────────────────────── */
|
||||||
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 */
|
|
||||||
.card {
|
.card {
|
||||||
background: white;
|
background: var(--card-bg);
|
||||||
border-radius: 10px;
|
border: var(--card-border);
|
||||||
padding: 24px;
|
border-radius: var(--card-radius);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
padding: 28px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
.card h2 {
|
/* ── Buttons ────────────────────────────────────────────────── */
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
padding: 10px 20px;
|
padding: 9px 20px; border-radius: 8px; border: none;
|
||||||
border-radius: 8px;
|
cursor: pointer; font-size: 0.875rem; font-weight: 500;
|
||||||
border: none;
|
text-decoration: none; transition: background 0.15s, opacity 0.15s; white-space: nowrap;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
[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 input,
|
||||||
.form-group select {
|
.form-group select,
|
||||||
width: 100%;
|
.form-group textarea {
|
||||||
padding: 10px 14px;
|
width: 100%; padding: 9px 13px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
border-radius: 8px;
|
font-size: 0.9rem; background: var(--input-bg); color: var(--text);
|
||||||
font-size: 0.9rem;
|
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 {
|
/* ── Question card (quiz-taking) ────────────────────────────── */
|
||||||
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 {
|
.question-card {
|
||||||
background: white;
|
background: var(--card-bg);
|
||||||
border-radius: 10px;
|
border: var(--card-border);
|
||||||
padding: 20px;
|
border-left: 4px solid var(--primary);
|
||||||
margin-bottom: 16px;
|
border-radius: var(--card-radius);
|
||||||
border-left: 4px solid #2563eb;
|
padding: 28px 28px 24px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
margin-bottom: 20px;
|
||||||
}
|
box-shadow: var(--card-shadow);
|
||||||
|
|
||||||
.question-card h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-card .options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.question-card .option {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 12px;
|
||||||
align-items: center;
|
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||||
gap: 10px;
|
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
||||||
padding: 10px 14px;
|
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.option-letter {
|
||||||
background: #f1f5f9;
|
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);
|
||||||
.question-card .option.selected {
|
transition: background 0.12s;
|
||||||
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.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 {
|
.explanation {
|
||||||
margin-top: 12px;
|
margin-top: 20px; padding: 18px 20px;
|
||||||
padding: 12px;
|
background: var(--expl-bg); border-left: 3px solid var(--expl-bd);
|
||||||
background: #f8fafc;
|
border-radius: 8px; font-size: 0.875rem; color: var(--text-muted);
|
||||||
border-radius: 8px;
|
line-height: 1.75; white-space: pre-line;
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #475569;
|
|
||||||
border-left: 3px solid #60a5fa;
|
|
||||||
}
|
}
|
||||||
|
.explanation strong { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
/* Stats */
|
/* ── Review card (results) ──────────────────────────────────── */
|
||||||
.stats-grid {
|
.review-card {
|
||||||
display: grid;
|
background: var(--card-bg); border: var(--card-border);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
border-radius: var(--card-radius); padding: 28px 28px 24px;
|
||||||
gap: 16px;
|
margin-bottom: 20px; box-shadow: var(--card-shadow);
|
||||||
margin-bottom: 24px;
|
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 {
|
/* ── Badges ─────────────────────────────────────────────────── */
|
||||||
background: white;
|
.badge { display: inline-block; padding: 3px 10px; border-radius: var(--badge-radius); font-size: 0.72rem; font-weight: 600; }
|
||||||
padding: 20px;
|
.badge-processing { background: #fef3c7; color: #92400e; }
|
||||||
border-radius: 10px;
|
.badge-ready { background: #d1fae5; color: #065f46; }
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .stat-value {
|
/* ── Stats ──────────────────────────────────────────────────── */
|
||||||
font-size: 2rem;
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||||
font-weight: 700;
|
.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; }
|
||||||
color: #2563eb;
|
.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 {
|
/* ── Grid ───────────────────────────────────────────────────── */
|
||||||
font-size: 0.85rem;
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
color: #64748b;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid layouts */
|
/* ── Alerts ─────────────────────────────────────────────────── */
|
||||||
.grid-2 {
|
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||||
display: grid;
|
.alert-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
grid-template-columns: 1fr 1fr;
|
.alert-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ── 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.good { color: #22c55e; }
|
||||||
.score-display .score-value.ok { color: #f59e0b; }
|
.score-display .score-value.ok { color: #f59e0b; }
|
||||||
.score-display .score-value.poor { color: #ef4444; }
|
.score-display .score-value.poor { color: #ef4444; }
|
||||||
|
|
||||||
/* Upload area */
|
/* ── Upload ─────────────────────────────────────────────────── */
|
||||||
.upload-area {
|
.upload-area { border: 2px dashed var(--border); border-radius: 12px; padding: 56px; text-align: center; cursor: pointer; transition: all 0.2s; }
|
||||||
border: 2px dashed #cbd5e1;
|
.upload-area:hover, .upload-area.dragging { border-color: var(--primary); background: var(--option-sel-bg); }
|
||||||
border-radius: 12px;
|
|
||||||
padding: 48px;
|
/* ── 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;
|
text-align: center;
|
||||||
cursor: pointer;
|
padding: 24px 0 32px;
|
||||||
transition: all 0.2s;
|
margin-top: 48px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area:hover {
|
/* ── Quiz page bottom spacing ───────────────────────────────── */
|
||||||
border-color: #2563eb;
|
.quiz-bottom { padding-bottom: 48px; }
|
||||||
background: #f8fafc;
|
.quiz-nav {
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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;
|
margin-top: 16px;
|
||||||
font-size: 0.9rem;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.quiz-nav-numbers {
|
||||||
.auth-card .auth-link a {
|
display: flex;
|
||||||
color: #2563eb;
|
gap: 6px;
|
||||||
text-decoration: none;
|
flex-wrap: wrap;
|
||||||
}
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
/* Loading */
|
min-width: 0;
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.grid-2 {
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||||
}
|
.question-card, .review-card { padding: 20px 16px; }
|
||||||
.stats-grid {
|
.card { padding: 20px; }
|
||||||
grid-template-columns: 1fr 1fr;
|
.quiz-nav { flex-wrap: wrap; }
|
||||||
}
|
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
frontend/src/pages/AccountPage.jsx
Normal file
129
frontend/src/pages/AccountPage.jsx
Normal 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} ·
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,24 +4,6 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
const TASKS = ['extraction', 'tts', 'general']
|
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() {
|
export default function AdminPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -34,6 +16,15 @@ export default function AdminPage() {
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
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(() => {
|
useEffect(() => {
|
||||||
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
||||||
|
|
@ -108,8 +99,28 @@ export default function AdminPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyPreset = (preset) => {
|
const searchLiteLLM = async () => {
|
||||||
setNewModel(m => ({ ...m, name: preset.name, model_id: preset.model_id }))
|
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>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||||
|
|
@ -119,6 +130,10 @@ export default function AdminPage() {
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
|
const filteredResults = searchFilter
|
||||||
|
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||||
|
: searchResults
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
@ -137,13 +152,83 @@ export default function AdminPage() {
|
||||||
|
|
||||||
{tab === 'models' && (
|
{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 => (
|
{TASKS.map(task => (
|
||||||
<div className="card" key={task}>
|
<div className="card" key={task}>
|
||||||
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
|
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
|
||||||
{task} Models
|
{task} Models
|
||||||
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
||||||
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
|
{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'}
|
'— General purpose AI'}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -173,7 +258,8 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="card">
|
{/* Add model form */}
|
||||||
|
<div className="card" id="add-model-form">
|
||||||
<h2>Add Model</h2>
|
<h2>Add Model</h2>
|
||||||
<form onSubmit={createModel}>
|
<form onSubmit={createModel}>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
|
|
@ -183,15 +269,6 @@ export default function AdminPage() {
|
||||||
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
|
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label>Display Name</label>
|
<label>Display Name</label>
|
||||||
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
|
<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' && (
|
{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">
|
<div className="card">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
|
|
@ -243,6 +349,7 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,103 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
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() {
|
export default function DashboardPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
const [documents, setDocuments] = useState([])
|
const [documents, setDocuments] = useState([])
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.get('/documents/'),
|
api.get('/documents/'),
|
||||||
api.get('/attempts/stats/dashboard'),
|
api.get('/attempts/stats/dashboard'),
|
||||||
]).then(([docsRes, statsRes]) => {
|
api.get('/attempts/history'),
|
||||||
|
]).then(([docsRes, statsRes, histRes]) => {
|
||||||
setDocuments(docsRes.data)
|
setDocuments(docsRes.data)
|
||||||
setStats(statsRes.data)
|
setStats(statsRes.data)
|
||||||
|
setHistory(histRes.data)
|
||||||
|
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||||
}).catch(console.error)
|
}).catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
|
||||||
|
</div>
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-card">
|
{[
|
||||||
<div className="stat-value">{stats.total_documents}</div>
|
{ value: stats.total_documents, label: 'Documents' },
|
||||||
<div className="stat-label">Documents</div>
|
{ value: stats.total_quizzes, label: 'Quizzes' },
|
||||||
</div>
|
{ value: stats.total_attempts, label: 'Attempts' },
|
||||||
<div className="stat-card">
|
{ value: `${stats.average_score}%`, label: 'Avg Score' },
|
||||||
<div className="stat-value">{stats.total_quizzes}</div>
|
].map(s => (
|
||||||
<div className="stat-label">Quizzes</div>
|
<div className="stat-card" key={s.label}>
|
||||||
</div>
|
<div className="stat-value">{s.value}</div>
|
||||||
<div className="stat-card">
|
<div className="stat-label">{s.label}</div>
|
||||||
<div className="stat-value">{stats.total_attempts}</div>
|
</div>
|
||||||
<div className="stat-label">Attempts</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
)}
|
||||||
<div className="stat-value">{stats.average_score}%</div>
|
|
||||||
<div className="stat-label">Avg Score</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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -48,11 +106,8 @@ export default function DashboardPage() {
|
||||||
<h2>Your Documents</h2>
|
<h2>Your Documents</h2>
|
||||||
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
|
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
|
||||||
<p>No documents yet. Upload a PDF to get started!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
documents.map(doc => (
|
documents.map(doc => (
|
||||||
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
|
@ -69,28 +124,6 @@ export default function DashboardPage() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export default function DocumentDetailPage() {
|
||||||
const [quizTitle, setQuizTitle] = useState('')
|
const [quizTitle, setQuizTitle] = useState('')
|
||||||
const [quizMode, setQuizMode] = useState('timed')
|
const [quizMode, setQuizMode] = useState('timed')
|
||||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState('')
|
||||||
|
const [availableModels, setAvailableModels] = useState([])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
|
|
@ -26,7 +28,16 @@ export default function DocumentDetailPage() {
|
||||||
}).catch(() => navigate('/'))
|
}).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(() => {
|
useEffect(() => {
|
||||||
if (!doc || doc.status !== 'processing') return
|
if (!doc || doc.status !== 'processing') return
|
||||||
|
|
@ -66,6 +77,7 @@ export default function DocumentDetailPage() {
|
||||||
title,
|
title,
|
||||||
mode: quizMode,
|
mode: quizMode,
|
||||||
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
||||||
|
model_id: selectedModelId || null,
|
||||||
})
|
})
|
||||||
navigate(`/quizzes/${res.data.id}`)
|
navigate(`/quizzes/${res.data.id}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -185,6 +197,18 @@ export default function DocumentDetailPage() {
|
||||||
placeholder="Leave blank for no limit" />
|
placeholder="Leave blank for no limit" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
70
frontend/src/pages/ForgotPasswordPage.jsx
Normal file
70
frontend/src/pages/ForgotPasswordPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,9 @@ export default function LoginPage() {
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="auth-link">
|
||||||
|
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||||
|
</div>
|
||||||
<div className="auth-link">
|
<div className="auth-link">
|
||||||
Don't have an account? <Link to="/register">Sign up</Link>
|
Don't have an account? <Link to="/register">Sign up</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
219
frontend/src/pages/QuizEditPage.jsx
Normal file
219
frontend/src/pages/QuizEditPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,68 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
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'
|
import api from '../api/client'
|
||||||
|
|
||||||
function TTSButton({ text }) {
|
function TTSButton({ text, voice, onActiveChange }) {
|
||||||
const [playing, setPlaying] = useState(false)
|
const [state, setState] = useState('idle') // idle | loading | playing
|
||||||
const audioRef = useRef(null)
|
const audioRef = useRef(null)
|
||||||
|
|
||||||
const speak = async () => {
|
// Stop audio when question changes (component unmounts)
|
||||||
if (playing) {
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
audioRef.current?.pause()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
if (state === 'loading') return
|
||||||
try {
|
try {
|
||||||
setPlaying(true)
|
setStateAndNotify('loading')
|
||||||
const res = await api.post('/tts/speak', { text }, { responseType: 'blob' })
|
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||||||
const url = URL.createObjectURL(res.data)
|
const url = URL.createObjectURL(res.data)
|
||||||
const audio = new Audio(url)
|
const audio = new Audio(url)
|
||||||
audioRef.current = audio
|
audioRef.current = audio
|
||||||
audio.onended = () => { setPlaying(false); URL.revokeObjectURL(url) }
|
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
|
||||||
audio.onerror = () => setPlaying(false)
|
audio.onerror = () => setStateAndNotify('idle')
|
||||||
audio.play()
|
audio.play()
|
||||||
} catch {
|
setStateAndNotify('playing')
|
||||||
setPlaying(false)
|
} 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 (
|
return (
|
||||||
<button
|
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
|
||||||
onClick={speak}
|
background: bg, color, border: 'none', borderRadius: '6px',
|
||||||
title={playing ? 'Stop' : 'Read aloud'}
|
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
|
||||||
style={{
|
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
|
||||||
background: playing ? '#ef4444' : '#e0e7ff',
|
}}>
|
||||||
color: playing ? 'white' : '#3730a3',
|
{label}
|
||||||
border: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '4px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{playing ? '⏹ Stop' : '🔊 Listen'}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimerDisplay({ seconds, total }) {
|
function TimerDisplay({ seconds, total }) {
|
||||||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60), secs = seconds % 60
|
||||||
const secs = seconds % 60
|
|
||||||
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
|
<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>
|
||||||
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
||||||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
|
<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() {
|
export default function QuizPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||||
const [quiz, setQuiz] = useState(null)
|
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 [answers, setAnswers] = useState({})
|
||||||
const [currentIdx, setCurrentIdx] = useState(0)
|
const [currentIdx, setCurrentIdx] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -76,214 +148,232 @@ export default function QuizPage() {
|
||||||
const [timeLeft, setTimeLeft] = useState(null)
|
const [timeLeft, setTimeLeft] = useState(null)
|
||||||
const [totalTime, setTotalTime] = useState(null)
|
const [totalTime, setTotalTime] = useState(null)
|
||||||
const timerRef = useRef(null)
|
const timerRef = useRef(null)
|
||||||
|
const hasStarted = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
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)
|
setQuiz(quizRes.data)
|
||||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
setVoices(voicesRes.data)
|
||||||
setAttemptId(attemptRes.data.id)
|
const def = voicesRes.data.find(v => v.is_default)
|
||||||
|
if (def) setSelectedVoice(def.id)
|
||||||
if (quizRes.data.mode === 'timed' && quizRes.data.time_limit_minutes) {
|
} catch { navigate('/') }
|
||||||
const secs = quizRes.data.time_limit_minutes * 60
|
finally { setLoading(false) }
|
||||||
setTimeLeft(secs)
|
|
||||||
setTotalTime(secs)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
navigate('/')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
return () => clearInterval(timerRef.current)
|
return () => clearInterval(timerRef.current)
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
// Timer countdown
|
const startQuiz = async (mode, voice) => {
|
||||||
useEffect(() => {
|
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 === null) return
|
||||||
if (timeLeft <= 0) {
|
if (timeLeft <= 0) { handleSubmit(true); return }
|
||||||
handleSubmit(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setTimeLeft(t => {
|
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
|
||||||
if (t <= 1) { clearInterval(timerRef.current); return 0 }
|
|
||||||
return t - 1
|
|
||||||
})
|
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return () => clearInterval(timerRef.current)
|
return () => clearInterval(timerRef.current)
|
||||||
}, [timeLeft === null])
|
}, [timeLeft === null])
|
||||||
|
|
||||||
const setAnswer = (questionId, value) => {
|
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [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) => {
|
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||||||
if (!attemptId || submitting) return
|
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)
|
clearInterval(timerRef.current)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const submission = {
|
const submission = {
|
||||||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||||||
question_id: parseInt(qid),
|
question_id: parseInt(qid), user_answer: answer,
|
||||||
user_answer: answer,
|
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
|
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
|
||||||
} finally {
|
} finally { setSubmitting(false) }
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}, [attemptId, answers, submitting])
|
}, [attemptId, answers, submitting])
|
||||||
|
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||||
if (!quiz) return null
|
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 questions = quiz.questions || []
|
||||||
const current = questions[currentIdx]
|
const current = questions[currentIdx]
|
||||||
const isLearning = quiz.mode === 'learning'
|
|
||||||
const answeredCount = Object.keys(answers).length
|
const answeredCount = Object.keys(answers).length
|
||||||
const totalCount = questions.length
|
const totalCount = questions.length
|
||||||
const isLast = currentIdx === totalCount - 1
|
const isLast = currentIdx === totalCount - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="quiz-bottom">
|
||||||
{/* Header */}
|
|
||||||
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
||||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: isLearning ? '#d1fae5' : '#e0e7ff',
|
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||||
color: isLearning ? '#065f46' : '#3730a3',
|
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
|
||||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8
|
|
||||||
}}>
|
}}>
|
||||||
{isLearning ? 'Learning Mode' : 'Timed Mode'}
|
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
|
||||||
</span>
|
</span>
|
||||||
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{timeLeft !== null && (
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
<TimerDisplay seconds={timeLeft} total={totalTime} />
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="progress-bar" style={{ marginBottom: 20 }}>
|
<div className="progress-bar" style={{ marginBottom: 20 }}>
|
||||||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current question */}
|
|
||||||
{current && (
|
{current && (
|
||||||
<div className="question-card">
|
<div className="question-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<h3 style={{ flex: 1 }}>
|
<h3 style={{ flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||||
Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}
|
<TTSButton
|
||||||
</h3>
|
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||||
<TTSButton text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} />
|
voice={selectedVoice}
|
||||||
|
onActiveChange={setTtsActive}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
|
<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'}
|
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Question image */}
|
|
||||||
{current.image_path && (
|
{current.image_path && (
|
||||||
<div style={{ margin: '12px 0' }}>
|
<div style={{ margin: '12px 0' }}>
|
||||||
<img
|
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||||
src={`/uploads/${current.image_path}`}
|
|
||||||
alt="Question illustration"
|
|
||||||
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Answer options */}
|
|
||||||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||||
<div className="options" style={{ marginTop: 12 }}>
|
<div className="options" style={{ marginTop: 12 }}>
|
||||||
{current.options.map((opt, i) => {
|
{current.options.map((opt, i) => {
|
||||||
const isSelected = answers[current.id] === opt
|
const isSelected = answers[current.id] === opt
|
||||||
const isCorrect = isLearning && opt === current.correct_answer
|
const hasAnswered = isStudy && !!answers[current.id]
|
||||||
const isWrong = isLearning && isSelected && opt !== current.correct_answer
|
const isCorrectOpt = opt === current.correct_answer
|
||||||
|
const showCorrect = hasAnswered && isCorrectOpt
|
||||||
|
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||||||
|
const letter = String.fromCharCode(65 + i)
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i}
|
||||||
key={i}
|
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||||
className={`option ${isSelected ? 'selected' : ''} ${isCorrect && isSelected ? 'correct' : ''} ${isWrong ? 'incorrect' : ''}`}
|
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||||||
onClick={() => setAnswer(current.id, opt)}
|
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<input type="radio" name={`q-${current.id}`} checked={isSelected} onChange={() => setAnswer(current.id, opt)} />
|
<span className="option-letter">{letter}</span>
|
||||||
<span>{opt}</span>
|
<span style={{ flex: 1 }}>{opt}</span>
|
||||||
{isLearning && isCorrect && <span style={{ marginLeft: 'auto', color: '#22c55e', fontWeight: 600 }}>✓ Correct</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input type="text" placeholder="Type your answer..."
|
||||||
type="text"
|
|
||||||
placeholder="Type your answer..."
|
|
||||||
value={answers[current.id] || ''}
|
value={answers[current.id] || ''}
|
||||||
onChange={e => setAnswer(current.id, e.target.value)}
|
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 */}
|
{/* Study mode: show explanation immediately after answering */}
|
||||||
{isLearning && answers[current.id] && current.explanation && (
|
{isStudy && answers[current.id] && (
|
||||||
<div className="explanation" style={{ marginTop: 16 }}>
|
<>
|
||||||
<strong>Explanation:</strong> {current.explanation}
|
{current.explanation && (
|
||||||
</div>
|
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||||||
)}
|
<strong>Explanation:</strong> {current.explanation}
|
||||||
|
</div>
|
||||||
{/* Learning mode: show correct answer for fill blank */}
|
)}
|
||||||
{isLearning && current.question_type === 'fill_blank' && answers[current.id] && (
|
{current.question_type === 'fill_blank' && (
|
||||||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||||||
<strong>Answer:</strong> {current.correct_answer}
|
<strong>Correct Answer:</strong> {current.correct_answer}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
<div className="quiz-nav">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
|
<button className="btn btn-secondary"
|
||||||
<button
|
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
|
|
||||||
disabled={currentIdx === 0}
|
disabled={currentIdx === 0}
|
||||||
>← Previous</button>
|
>← Previous</button>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
<div className="quiz-nav-numbers">
|
||||||
{questions.map((q, i) => (
|
{questions.map((q, i) => (
|
||||||
<button
|
<button key={q.id} onClick={() => safeNavigate(i)} style={{
|
||||||
key={q.id}
|
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
|
||||||
onClick={() => setCurrentIdx(i)}
|
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
|
||||||
style={{
|
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
|
||||||
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
|
}}>{i + 1}</button>
|
||||||
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
|
|
||||||
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
|
|
||||||
}}
|
|
||||||
>{i + 1}</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<button
|
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => handleSubmit(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button className="btn btn-primary" onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}>Next →</button>
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}
|
|
||||||
>Next →</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,34 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
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() {
|
export default function QuizzesPage() {
|
||||||
const [quizzes, setQuizzes] = useState([])
|
const [quizzes, setQuizzes] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
api.get('/quizzes/').then(res => setQuizzes(res.data))
|
api.get('/quizzes/').then(res => setQuizzes(res.data))
|
||||||
|
|
@ -12,32 +36,199 @@ export default function QuizzesPage() {
|
||||||
.finally(() => setLoading(false))
|
.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>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
{/* Search bar */}
|
||||||
<h2>Your Quizzes</h2>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
{quizzes.length === 0 ? (
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<div className="empty-state">
|
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
||||||
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
|
@ -8,16 +9,22 @@ export default function RegisterPage() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { register } = useAuth()
|
const [done, setDone] = useState(false)
|
||||||
const navigate = useNavigate()
|
const { loginWithToken } = useAuth()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await register(email, password, name)
|
const res = await api.post('/auth/register', { email, password, name })
|
||||||
navigate('/')
|
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) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.detail || 'Registration failed')
|
setError(err.response?.data?.detail || 'Registration failed')
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
|
|
@ -41,7 +69,7 @@ export default function RegisterPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Password</label>
|
<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>
|
</div>
|
||||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||||
{loading ? 'Creating account...' : 'Sign Up'}
|
{loading ? 'Creating account...' : 'Sign Up'}
|
||||||
|
|
|
||||||
73
frontend/src/pages/ResetPasswordPage.jsx
Normal file
73
frontend/src/pages/ResetPasswordPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,55 +21,146 @@ export default function ResultsPage() {
|
||||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
|
if (loading) return <div className="loading"><div className="spinner"></div> Loading results...</div>
|
||||||
if (!result) return null
|
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 (
|
return (
|
||||||
<div>
|
<div style={{ maxWidth: 780, margin: '0 auto' }}>
|
||||||
<div className="card">
|
{/* Score card */}
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
<div className="score-display">
|
<div className="score-display">
|
||||||
<div className={`score-value ${scoreClass}`}>{result.percentage}%</div>
|
<div className={`score-value ${scoreClass}`}>{pct}%</div>
|
||||||
<div style={{ fontSize: '1.1rem', color: '#64748b', marginTop: 8 }}>
|
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
|
||||||
{result.score} out of {result.total_questions} correct
|
{correct} of {total} correct
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 16, color: '#475569' }}>
|
<div style={{ marginTop: 10, fontSize: '0.95rem', color: 'var(--text-muted)' }}>
|
||||||
{result.percentage >= 90 ? 'Excellent! You\'ve mastered this material.' :
|
{pct >= 90 ? "Excellent — you've mastered this material." :
|
||||||
result.percentage >= 70 ? 'Good job! A few more reviews and you\'ll nail it.' :
|
pct >= 75 ? 'Good work! Review any missed questions below.' :
|
||||||
result.percentage >= 50 ? 'Getting there! Keep practicing.' :
|
pct >= 50 ? 'Getting there — study the explanations and retake.' :
|
||||||
'Don\'t worry — review the explanations below and try again!'}
|
'Review all explanations carefully before retaking.'}
|
||||||
</div>
|
</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/${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>
|
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style={{ marginBottom: 16 }}>Question Review</h2>
|
{/* Summary row */}
|
||||||
{result.answers?.map((ans, idx) => (
|
{result.answers && result.answers.length > 0 && (
|
||||||
<div className="question-card" key={idx} style={{
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||||
borderLeftColor: ans.is_correct ? '#22c55e' : '#ef4444'
|
{[
|
||||||
}}>
|
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
||||||
<h3>Q{idx + 1}. {ans.question_text}</h3>
|
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
|
||||||
<div style={{ marginTop: 8 }}>
|
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
].map(s => (
|
||||||
<span style={{ fontWeight: 600, color: ans.is_correct ? '#22c55e' : '#ef4444' }}>
|
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
||||||
{ans.is_correct ? 'Correct' : 'Incorrect'}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.9rem' }}>
|
|
||||||
<div><strong>Your answer:</strong> {ans.user_answer || '(no answer)'}</div>
|
{/* Options with letter badges */}
|
||||||
{!ans.is_correct && (
|
{ans.options && ans.options.length > 0 && (
|
||||||
<div style={{ color: '#22c55e' }}><strong>Correct answer:</strong> {ans.correct_answer}</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||||
)}
|
{ans.options.map((opt, i) => {
|
||||||
</div>
|
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>
|
</div>
|
||||||
{ans.explanation && (
|
)
|
||||||
<div className="explanation">
|
})}
|
||||||
<strong>Explanation:</strong> {ans.explanation}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
frontend/src/pages/SettingsPage.jsx
Normal file
203
frontend/src/pages/SettingsPage.jsx
Normal 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}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/pages/VerifyEmailPage.jsx
Normal file
43
frontend/src/pages/VerifyEmailPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue