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_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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from app.utils.scheduler import start_scheduler, stop_scheduler
|
|||
def seed_admin():
|
||||
"""Create default admin user if none exists."""
|
||||
from app.models.user import User
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.password_reset import PasswordReset
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin_exists = db.query(User).filter(User.role == "admin").first()
|
||||
|
|
@ -26,7 +28,29 @@ def seed_admin():
|
|||
role="admin",
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.flush()
|
||||
# Auto-verify seeded admin
|
||||
from datetime import datetime
|
||||
db.add(EmailVerification(
|
||||
user_id=admin_user.id,
|
||||
token="seeded",
|
||||
expires_at=datetime.utcnow(),
|
||||
verified_at=datetime.utcnow(),
|
||||
))
|
||||
db.commit()
|
||||
else:
|
||||
# Ensure existing admin has a verified email record
|
||||
from app.models.email_verification import EmailVerification
|
||||
from datetime import datetime
|
||||
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
|
||||
if not existing_v:
|
||||
db.add(EmailVerification(
|
||||
user_id=admin_exists.id,
|
||||
token=f"legacy_{admin_exists.id}",
|
||||
expires_at=datetime.utcnow(),
|
||||
verified_at=datetime.utcnow(),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
|
@ -38,57 +62,123 @@ def seed_default_models():
|
|||
try:
|
||||
if db.query(AIModelConfig).count() == 0:
|
||||
defaults = [
|
||||
AIModelConfig(
|
||||
name="GPT-4o Mini (Extraction)",
|
||||
model_id="gpt-4o-mini",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=True,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="GPT-4o (Extraction)",
|
||||
model_id="gpt-4o",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="Claude Sonnet 4.6 (Extraction)",
|
||||
model_id="anthropic/claude-sonnet-4-6-20250514",
|
||||
task="extraction",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="Google Vertex TTS",
|
||||
model_id="vertex_ai/google/cloud-tts",
|
||||
task="tts",
|
||||
is_active=True,
|
||||
is_default=True,
|
||||
),
|
||||
AIModelConfig(
|
||||
name="OpenAI TTS",
|
||||
model_id="openai/tts-1",
|
||||
task="tts",
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
),
|
||||
AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True),
|
||||
AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False),
|
||||
AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False),
|
||||
AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False),
|
||||
]
|
||||
db.add_all(defaults)
|
||||
db.commit()
|
||||
|
||||
# Always ensure OpenAI TTS voice models exist (idempotent)
|
||||
tts_voices = [
|
||||
# OpenAI (work with OPENAI_API_KEY)
|
||||
("OpenAI Alloy", "tts-1:alloy", True),
|
||||
("OpenAI Nova", "tts-1:nova", False),
|
||||
("OpenAI Echo", "tts-1:echo", False),
|
||||
("OpenAI Shimmer", "tts-1:shimmer", False),
|
||||
("OpenAI Onyx", "tts-1:onyx", False),
|
||||
("OpenAI Fable", "tts-1:fable", False),
|
||||
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
|
||||
("OpenAI Nova HD", "tts-1-hd:nova", False),
|
||||
# ElevenLabs (work with ELEVENLABS_API_KEY)
|
||||
("ElevenLabs Adam", "elevenlabs/adam", False),
|
||||
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
|
||||
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
|
||||
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
|
||||
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
|
||||
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
|
||||
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
|
||||
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
|
||||
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
|
||||
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
|
||||
("AWS Polly Amy (en-GB)", "polly/Amy", False),
|
||||
("AWS Polly Brian (en-GB)", "polly/Brian", False),
|
||||
]
|
||||
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
|
||||
for old_id in ("tts-1", "tts-1-hd"):
|
||||
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
|
||||
if old:
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
|
||||
has_default_tts = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
|
||||
).first() is not None
|
||||
|
||||
for name, model_id, _ in tts_voices:
|
||||
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||
if not exists:
|
||||
is_def = not has_default_tts
|
||||
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
|
||||
if is_def:
|
||||
has_default_tts = True
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_pgvector():
|
||||
"""Enable pgvector extension and add embedding column if missing."""
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS embedding vector(1024)
|
||||
"""))
|
||||
# HNSW index for fast cosine similarity — created only if not exists
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
|
||||
ON questions USING hnsw (embedding vector_cosine_ops)
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def backfill_embeddings():
|
||||
"""Generate embeddings for questions that don't have one yet (background, best-effort)."""
|
||||
import threading
|
||||
from app.models.question import Question
|
||||
from app.services import embedding_service
|
||||
|
||||
def _run():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
missing = db.query(Question).filter(Question.embedding.is_(None)).all()
|
||||
if not missing:
|
||||
return
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
log.info(f"Backfilling embeddings for {len(missing)} questions...")
|
||||
ok = 0
|
||||
for q in missing:
|
||||
try:
|
||||
if embedding_service.embed_question(q):
|
||||
ok += 1
|
||||
except Exception:
|
||||
pass
|
||||
db.commit()
|
||||
log.info(f"Backfill complete: {ok}/{len(missing)} embedded")
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Embedding backfill failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup_pgvector()
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True)
|
||||
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
||||
seed_admin()
|
||||
seed_default_models()
|
||||
backfill_embeddings()
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
|
|
@ -96,15 +186,15 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
|
||||
app = FastAPI(
|
||||
title="PDF Quiz Generator",
|
||||
description="Convert PDF files into interactive quizzes with AI",
|
||||
title="PedQuiz",
|
||||
description="Pediatric Knowledge Quiz Platform",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"],
|
||||
allow_origins=["https://quiz.danvics.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ class QuizAttempt(Base):
|
|||
__tablename__ = "quiz_attempts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
score = Column(Integer, default=0)
|
||||
total_questions = Column(Integer, default=0)
|
||||
started_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
|
@ -26,8 +26,8 @@ class AttemptAnswer(Base):
|
|||
__tablename__ = "attempt_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False)
|
||||
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False)
|
||||
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False)
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
||||
user_answer = Column(String, nullable=False)
|
||||
is_correct = Column(Boolean, default=False)
|
||||
|
||||
|
|
|
|||
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"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
original_filename = Column(String, nullable=False)
|
||||
total_pages = Column(Integer, nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
|
||||
|
||||
|
|
@ -8,13 +10,14 @@ class Question(Base):
|
|||
__tablename__ = "questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
||||
question_text = Column(Text, nullable=False)
|
||||
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
|
||||
options = Column(JSON, nullable=True) # list of strings for mcq
|
||||
correct_answer = Column(String, nullable=False)
|
||||
explanation = Column(Text, nullable=True)
|
||||
page_reference = Column(Integer, nullable=True)
|
||||
image_path = Column(String, nullable=True) # path to extracted image, if any
|
||||
image_path = Column(String, nullable=True)
|
||||
embedding = Column(Vector(1024), nullable=True) # semantic search vector
|
||||
|
||||
quiz = relationship("Quiz", back_populates="questions")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
|
@ -16,9 +16,11 @@ class Quiz(Base):
|
|||
questions_count = Column(Integer, default=0)
|
||||
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
|
||||
mode = Column(String, default="timed") # timed, learning
|
||||
skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
section = relationship("Section", back_populates="quizzes")
|
||||
user = relationship("User", back_populates="quizzes")
|
||||
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
|
||||
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
|
||||
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ class ReminderSchedule(Base):
|
|||
__tablename__ = "reminder_schedules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
||||
next_reminder_at = Column(DateTime, nullable=False)
|
||||
interval_days = Column(Integer, default=1)
|
||||
performance_score = Column(Float, default=0.0)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Section(Base):
|
|||
__tablename__ = "sections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
document_id = Column(Integer, ForeignKey("pdf_documents.id"), nullable=False)
|
||||
document_id = Column(Integer, ForeignKey("pdf_documents.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
start_page = Column(Integer, nullable=False)
|
||||
end_page = Column(Integer, nullable=False)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.schemas.auth import UserResponse, UserUpdateRole
|
||||
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
|
||||
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
|
||||
from app.utils.auth import require_admin
|
||||
from app.utils.auth import require_admin, get_current_user, get_password_hash
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -43,8 +45,88 @@ def update_user_role(
|
|||
return user
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
def create_user(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Admin creates a user directly — email is auto-verified."""
|
||||
from app.models.email_verification import EmailVerification
|
||||
from datetime import datetime
|
||||
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role="user",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(EmailVerification(
|
||||
user_id=user.id,
|
||||
token=f"admin_created_{user.id}",
|
||||
expires_at=datetime.utcnow(),
|
||||
verified_at=datetime.utcnow(),
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# --- AI Model Configuration ---
|
||||
|
||||
@router.get("/models/available")
|
||||
def list_available_models(
|
||||
task: str = Query("extraction"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Returns active models for a given task — for users to choose when taking/creating a quiz."""
|
||||
models = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == task,
|
||||
AIModelConfig.is_active == True,
|
||||
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||
result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
|
||||
# Always include env default as fallback if nothing configured
|
||||
if not result:
|
||||
result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/litellm/models")
|
||||
def search_litellm_models(
|
||||
api_key: str = Query(None),
|
||||
api_base: str = Query(None),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
|
||||
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
|
||||
if base:
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = sorted([m["id"] for m in data.get("data", [])])
|
||||
return {"models": models, "source": base}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
|
||||
|
||||
# Fall back to LiteLLM's built-in model list
|
||||
try:
|
||||
import litellm
|
||||
models = sorted(litellm.utils.get_valid_models())
|
||||
return {"models": models, "source": "litellm-builtin"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get LiteLLM models: {e}")
|
||||
|
||||
|
||||
@router.get("/models", response_model=list[AIModelConfigResponse])
|
||||
def list_models(
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -62,7 +144,6 @@ def create_model(
|
|||
if data.task not in ("extraction", "tts", "general"):
|
||||
raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
|
||||
|
||||
# If setting as default, unset other defaults for same task
|
||||
if data.is_default:
|
||||
db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == data.task,
|
||||
|
|
@ -89,7 +170,6 @@ def update_model(
|
|||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# If setting as default, unset other defaults for same task
|
||||
task = update_data.get("task", model.task)
|
||||
if update_data.get("is_default"):
|
||||
db.query(AIModelConfig).filter(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ def start_attempt(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first()
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
|
|
@ -74,33 +74,37 @@ def submit_attempt(
|
|||
}
|
||||
|
||||
score = 0
|
||||
answer_details = []
|
||||
submitted = {ans.question_id: ans.user_answer for ans in submission.answers}
|
||||
|
||||
# Save submitted answers
|
||||
for ans in submission.answers:
|
||||
question = questions.get(ans.question_id)
|
||||
if not question:
|
||||
continue
|
||||
|
||||
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
|
||||
if is_correct:
|
||||
score += 1
|
||||
|
||||
attempt_answer = AttemptAnswer(
|
||||
db.add(AttemptAnswer(
|
||||
attempt_id=attempt_id,
|
||||
question_id=ans.question_id,
|
||||
user_answer=ans.user_answer,
|
||||
is_correct=is_correct,
|
||||
)
|
||||
db.add(attempt_answer)
|
||||
))
|
||||
|
||||
# Build full review — include ALL questions, unanswered marked as incorrect
|
||||
answer_details = []
|
||||
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all():
|
||||
user_answer = submitted.get(q.id, "")
|
||||
is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=question.id,
|
||||
question_text=question.question_text,
|
||||
question_type=question.question_type,
|
||||
user_answer=ans.user_answer,
|
||||
correct_answer=question.correct_answer,
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=user_answer,
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=is_correct,
|
||||
explanation=question.explanation,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
|
||||
attempt.score = score
|
||||
|
|
@ -153,6 +157,39 @@ def list_attempts(
|
|||
]
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def get_quiz_history(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return per-quiz attempt history for the performance line graph."""
|
||||
completed = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
).order_by(QuizAttempt.completed_at).all()
|
||||
|
||||
# Group by quiz
|
||||
from collections import defaultdict
|
||||
by_quiz: dict = defaultdict(list)
|
||||
quiz_titles: dict = {}
|
||||
for a in completed:
|
||||
pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1)
|
||||
by_quiz[a.quiz_id].append({
|
||||
"date": a.completed_at.isoformat(),
|
||||
"percentage": pct,
|
||||
"score": a.score,
|
||||
"total": a.total_questions,
|
||||
})
|
||||
if a.quiz_id not in quiz_titles:
|
||||
quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first()
|
||||
quiz_titles[a.quiz_id] = quiz.title if quiz else f"Quiz {a.quiz_id}"
|
||||
|
||||
return [
|
||||
{"quiz_id": qid, "title": quiz_titles[qid], "attempts": attempts}
|
||||
for qid, attempts in by_quiz.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats/dashboard", response_model=DashboardStats)
|
||||
def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -210,17 +247,20 @@ def get_attempt(
|
|||
if not attempt:
|
||||
raise HTTPException(status_code=404, detail="Attempt not found")
|
||||
|
||||
# Show ALL questions in review, not just answered ones
|
||||
submitted_map = {ans.question_id: ans for ans in attempt.answers}
|
||||
answer_details = []
|
||||
for ans in attempt.answers:
|
||||
question = ans.question
|
||||
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all():
|
||||
ans = submitted_map.get(q.id)
|
||||
answer_details.append(AnswerDetail(
|
||||
question_id=question.id,
|
||||
question_text=question.question_text,
|
||||
question_type=question.question_type,
|
||||
user_answer=ans.user_answer,
|
||||
correct_answer=question.correct_answer,
|
||||
is_correct=ans.is_correct,
|
||||
explanation=question.explanation,
|
||||
question_id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=q.options,
|
||||
user_answer=ans.user_answer if ans else "",
|
||||
correct_answer=q.correct_answer,
|
||||
is_correct=ans.is_correct if ans else False,
|
||||
explanation=q.explanation,
|
||||
))
|
||||
|
||||
percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
||||
|
|
|
|||
|
|
@ -1,49 +1,224 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest
|
||||
from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user
|
||||
from app.models.email_verification import EmailVerification
|
||||
from app.models.password_reset import PasswordReset
|
||||
from app.schemas.auth import (
|
||||
UserCreate, UserResponse, Token, LoginRequest,
|
||||
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from app.services import email_service
|
||||
from app.utils.auth import (
|
||||
get_password_hash, verify_password, create_access_token, get_current_user,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple in-memory login rate limiter: max 10 attempts per IP per 15 min
|
||||
_login_attempts: dict = defaultdict(list)
|
||||
|
||||
@router.post("/register", response_model=Token)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing:
|
||||
def _check_login_rate_limit(client_ip: str):
|
||||
now = datetime.utcnow()
|
||||
window = now - timedelta(minutes=15)
|
||||
attempts = [t for t in _login_attempts[client_ip] if t > window]
|
||||
_login_attempts[client_ip] = attempts
|
||||
if len(attempts) >= 10:
|
||||
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
||||
_login_attempts[client_ip].append(now)
|
||||
|
||||
|
||||
# Rate limit: max 3 reset requests per email per hour
|
||||
RESET_LIMIT = 3
|
||||
RESET_WINDOW_HOURS = 1
|
||||
|
||||
|
||||
def _check_reset_rate_limit(db: Session, email: str):
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
return # silently ignore unknown emails
|
||||
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
|
||||
count = db.query(PasswordReset).filter(
|
||||
PasswordReset.user_id == user.id,
|
||||
PasswordReset.created_at >= window_start,
|
||||
).count()
|
||||
if count >= RESET_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many reset requests. Please wait before trying again.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
if len(user_data.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
is_first_user = db.query(User).count() == 0
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role="admin" if is_first_user else "user",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
# Create email verification record
|
||||
token = secrets.token_urlsafe(32)
|
||||
verification = EmailVerification(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=24),
|
||||
verified_at=datetime.utcnow() if is_first_user else None,
|
||||
)
|
||||
db.add(verification)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
if is_first_user:
|
||||
# Auto-verified admin — log them in immediately
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
# Regular user — must verify email before logging in
|
||||
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
||||
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
|
||||
if request:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_login_rate_limit(client_ip)
|
||||
|
||||
user = db.query(User).filter(User.email == login_data.email).first()
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
# Check email verification — skip for users without any verification record (legacy/seeded)
|
||||
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
||||
if verification and verification.verified_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
status_code=403,
|
||||
detail="Email not verified. Please check your inbox and verify your email before logging in.",
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/verify-email")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
record = db.query(EmailVerification).filter(EmailVerification.token == token).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="Invalid verification token")
|
||||
if record.verified_at is not None:
|
||||
return {"message": "Email already verified. You can log in."}
|
||||
if datetime.utcnow() > record.expires_at:
|
||||
raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.")
|
||||
|
||||
record.verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Email verified successfully! You can now log in."}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == data.email).first()
|
||||
if not user:
|
||||
return {"message": "If that email exists, a verification link has been sent."}
|
||||
|
||||
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
||||
if record and record.verified_at is not None:
|
||||
return {"message": "Email already verified."}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
if record:
|
||||
record.token = token
|
||||
record.expires_at = datetime.utcnow() + timedelta(hours=24)
|
||||
else:
|
||||
db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
|
||||
db.commit()
|
||||
|
||||
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
||||
return {"message": "If that email exists, a verification link has been sent."}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
_check_reset_rate_limit(db, data.email)
|
||||
|
||||
user = db.query(User).filter(User.email == data.email).first()
|
||||
# Always return same message to avoid email enumeration
|
||||
if not user:
|
||||
return {"message": "If that email is registered, a reset link has been sent."}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
reset = PasswordReset(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
db.add(reset)
|
||||
db.commit()
|
||||
|
||||
background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
|
||||
return {"message": "If that email is registered, a reset link has been sent."}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
record = db.query(PasswordReset).filter(
|
||||
PasswordReset.token == data.token,
|
||||
PasswordReset.used == False,
|
||||
).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=400, detail="Invalid or already used reset token")
|
||||
if datetime.utcnow() > record.expires_at:
|
||||
raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
|
||||
|
||||
user = db.query(User).filter(User.id == record.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.hashed_password = get_password_hash(data.new_password)
|
||||
record.used = True
|
||||
db.commit()
|
||||
return {"message": "Password reset successfully. You can now log in."}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
if data.new_password:
|
||||
if not data.current_password:
|
||||
raise HTTPException(status_code=400, detail="Current password required to set a new one")
|
||||
if not verify_password(data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
current_user.hashed_password = get_password_hash(data.new_password)
|
||||
|
||||
if data.name:
|
||||
current_user.name = data.name
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def upload_document(
|
|||
|
||||
# Save file to disk streaming (handles large files)
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
safe_name = f"{uuid.uuid4()}_{file.filename}"
|
||||
safe_name = f"{uuid.uuid4()}_{os.path.basename(file.filename)}"
|
||||
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
|
||||
|
||||
with open(file_path, "wb") as buffer:
|
||||
|
|
@ -140,6 +140,10 @@ def create_section(
|
|||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if doc.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Document is not ready yet")
|
||||
if section_data.start_page < 1 or section_data.end_page < 1:
|
||||
raise HTTPException(status_code=400, detail="Page numbers must be positive")
|
||||
if section_data.start_page >= section_data.end_page:
|
||||
raise HTTPException(status_code=400, detail="start_page must be less than end_page")
|
||||
if doc.total_pages and section_data.end_page > doc.total_pages:
|
||||
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import random
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import cast, String, or_, and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question as QuestionModel
|
||||
from app.models.section import Section
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.user import User
|
||||
|
|
@ -37,6 +40,7 @@ def create_quiz(
|
|||
title=quiz_data.title,
|
||||
mode=quiz_data.mode,
|
||||
time_limit_minutes=quiz_data.time_limit_minutes,
|
||||
model_id=quiz_data.model_id,
|
||||
)
|
||||
return quiz
|
||||
except ValueError as e:
|
||||
|
|
@ -45,6 +49,119 @@ def create_quiz(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_quizzes(
|
||||
q: str = Query(..., min_length=2, max_length=200),
|
||||
mode: str = Query("all"), # "title" | "questions" | "all"
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Hybrid semantic + keyword search across quiz titles and questions."""
|
||||
from sqlalchemy import text as sa_text
|
||||
from app.services import embedding_service
|
||||
|
||||
phrase = q.strip()
|
||||
if not phrase:
|
||||
return []
|
||||
|
||||
results = {} # quiz_id -> result dict
|
||||
|
||||
def _ensure_quiz(quiz_id: int, match_type: str):
|
||||
if quiz_id not in results:
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
return False
|
||||
results[quiz_id] = {
|
||||
"quiz_id": quiz.id,
|
||||
"quiz_title": quiz.title,
|
||||
"questions_count": quiz.questions_count,
|
||||
"mode": quiz.mode,
|
||||
"time_limit_minutes": quiz.time_limit_minutes,
|
||||
"match_type": match_type,
|
||||
"matching_questions": [],
|
||||
}
|
||||
elif results[quiz_id]["match_type"] != match_type and match_type != "title":
|
||||
results[quiz_id]["match_type"] = "both"
|
||||
return True
|
||||
|
||||
# ── Title search ─────────────────────────────────────────────
|
||||
if mode in ("title", "all"):
|
||||
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all():
|
||||
_ensure_quiz(quiz.id, "title")
|
||||
|
||||
# ── Semantic (vector) search ──────────────────────────────────
|
||||
seen_question_ids = set()
|
||||
if mode in ("questions", "all"):
|
||||
query_emb = embedding_service.generate_embedding(phrase)
|
||||
if query_emb:
|
||||
# Use f-string for the vector literal — safe because it's a list of floats
|
||||
emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]"
|
||||
rows = db.execute(sa_text(f"""
|
||||
SELECT q.id, q.quiz_id, q.question_text, q.options,
|
||||
1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity
|
||||
FROM questions q
|
||||
WHERE q.embedding IS NOT NULL
|
||||
ORDER BY q.embedding <=> '{emb_literal}'::vector
|
||||
LIMIT 40
|
||||
""")).fetchall()
|
||||
|
||||
for row in rows:
|
||||
similarity = float(row.similarity)
|
||||
if similarity < 0.30:
|
||||
continue
|
||||
if _ensure_quiz(row.quiz_id, "questions"):
|
||||
seen_question_ids.add(row.id)
|
||||
results[row.quiz_id]["matching_questions"].append({
|
||||
"id": row.id,
|
||||
"question_text": row.question_text,
|
||||
"options": row.options,
|
||||
"similarity": round(similarity, 3),
|
||||
"match_source": "semantic",
|
||||
})
|
||||
|
||||
# ── Keyword (ILIKE) search ────────────────────────────────────
|
||||
if mode in ("questions", "all"):
|
||||
q_filter = or_(
|
||||
QuestionModel.question_text.ilike(f"%{phrase}%"),
|
||||
cast(QuestionModel.options, String).ilike(f"%{phrase}%"),
|
||||
)
|
||||
keyword_rows = (
|
||||
db.query(QuestionModel)
|
||||
.filter(q_filter)
|
||||
.order_by(QuestionModel.quiz_id, QuestionModel.id)
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
for question in keyword_rows:
|
||||
if _ensure_quiz(question.quiz_id, "questions"):
|
||||
if question.id not in seen_question_ids:
|
||||
seen_question_ids.add(question.id)
|
||||
results[question.quiz_id]["matching_questions"].append({
|
||||
"id": question.id,
|
||||
"question_text": question.question_text,
|
||||
"options": question.options,
|
||||
"similarity": None,
|
||||
"match_source": "keyword",
|
||||
})
|
||||
|
||||
# Sort each quiz's questions: semantic first (by similarity desc), then keyword
|
||||
for r in results.values():
|
||||
r["matching_questions"].sort(
|
||||
key=lambda x: (0 if x.get("match_source") == "semantic" else 1,
|
||||
-(x.get("similarity") or 0))
|
||||
)
|
||||
|
||||
# Sort results: title matches first, then by number of semantic hits desc
|
||||
sorted_results = sorted(
|
||||
results.values(),
|
||||
key=lambda r: (
|
||||
0 if r["match_type"] == "title" else 1,
|
||||
-sum(1 for q in r["matching_questions"] if q.get("match_source") == "semantic"),
|
||||
),
|
||||
)
|
||||
return sorted_results
|
||||
|
||||
|
||||
@router.get("/", response_model=list[QuizResponse])
|
||||
def list_quizzes(
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -61,19 +178,65 @@ def list_quizzes(
|
|||
@router.get("/{quiz_id}")
|
||||
def get_quiz(
|
||||
quiz_id: int,
|
||||
study: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get quiz for taking. Learning mode includes answers."""
|
||||
"""Get quiz for taking. study=true forces answers/explanations to be included."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
if quiz.mode == "learning":
|
||||
if study or quiz.mode == "learning":
|
||||
return QuizLearningDetail.model_validate(quiz)
|
||||
return QuizDetail.model_validate(quiz)
|
||||
|
||||
|
||||
@router.post("/{quiz_id}/shuffle", response_model=QuizDetail)
|
||||
def shuffle_quiz(
|
||||
quiz_id: int,
|
||||
shuffle_options: bool = Query(True, description="Also shuffle answer options within each question"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return quiz with question order and optionally option order shuffled. Does not modify DB."""
|
||||
from app.models.question import Question as QuestionModel
|
||||
from app.schemas.quiz import QuestionResponse
|
||||
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).all()
|
||||
shuffled_questions = questions.copy()
|
||||
random.shuffle(shuffled_questions)
|
||||
|
||||
result = []
|
||||
for q in shuffled_questions:
|
||||
options = list(q.options) if q.options else None
|
||||
if shuffle_options and options:
|
||||
random.shuffle(options)
|
||||
result.append(QuestionResponse(
|
||||
id=q.id,
|
||||
question_text=q.question_text,
|
||||
question_type=q.question_type,
|
||||
options=options,
|
||||
image_path=q.image_path,
|
||||
))
|
||||
|
||||
return {
|
||||
"id": quiz.id,
|
||||
"section_id": quiz.section_id,
|
||||
"user_id": quiz.user_id,
|
||||
"title": quiz.title,
|
||||
"questions_count": quiz.questions_count,
|
||||
"mode": quiz.mode,
|
||||
"time_limit_minutes": quiz.time_limit_minutes,
|
||||
"created_at": quiz.created_at,
|
||||
"questions": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{quiz_id}/review", response_model=QuizReview)
|
||||
def review_quiz(
|
||||
quiz_id: int,
|
||||
|
|
@ -96,6 +259,96 @@ def review_quiz(
|
|||
return quiz
|
||||
|
||||
|
||||
@router.get("/{quiz_id}/questions")
|
||||
def get_quiz_questions_for_edit(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Get all questions with answers for editing — moderator/admin only."""
|
||||
from app.models.question import Question as QuestionModel
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).order_by(QuestionModel.id).all()
|
||||
return [
|
||||
{
|
||||
"id": q.id,
|
||||
"question_text": q.question_text,
|
||||
"question_type": q.question_type,
|
||||
"options": q.options,
|
||||
"correct_answer": q.correct_answer,
|
||||
"explanation": q.explanation,
|
||||
}
|
||||
for q in questions
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{quiz_id}/questions/{question_id}")
|
||||
def update_question(
|
||||
quiz_id: int,
|
||||
question_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Update a quiz question — moderator/admin only."""
|
||||
from app.models.question import Question as QuestionModel
|
||||
question = db.query(QuestionModel).filter(
|
||||
QuestionModel.id == question_id,
|
||||
QuestionModel.quiz_id == quiz_id,
|
||||
).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
|
||||
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type"}
|
||||
for key, value in data.items():
|
||||
if key in allowed:
|
||||
setattr(question, key, value)
|
||||
|
||||
# Validate correct_answer is one of the options
|
||||
if question.options and question.correct_answer not in question.options:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"correct_answer must match one of the options exactly. Options: {question.options}"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(question)
|
||||
return {
|
||||
"id": question.id,
|
||||
"question_text": question.question_text,
|
||||
"question_type": question.question_type,
|
||||
"options": question.options,
|
||||
"correct_answer": question.correct_answer,
|
||||
"explanation": question.explanation,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{quiz_id}/questions/{question_id}", status_code=204)
|
||||
def delete_question(
|
||||
quiz_id: int,
|
||||
question_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Delete a single question from a quiz — moderator/admin only."""
|
||||
from app.models.question import Question as QuestionModel
|
||||
question = db.query(QuestionModel).filter(
|
||||
QuestionModel.id == question_id,
|
||||
QuestionModel.quiz_id == quiz_id,
|
||||
).first()
|
||||
if not question:
|
||||
raise HTTPException(status_code=404, detail="Question not found")
|
||||
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if quiz and quiz.questions_count > 0:
|
||||
quiz.questions_count -= 1
|
||||
|
||||
db.delete(question)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.delete("/{quiz_id}", status_code=204)
|
||||
def delete_quiz(
|
||||
quiz_id: int,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ from fastapi.responses import Response
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.services import ai_service
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
|
|
@ -13,6 +15,22 @@ router = APIRouter()
|
|||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: str | None = None # model_id override
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
def get_voices(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return available TTS voices from DB + env fallback."""
|
||||
db_models = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||
|
||||
voices = [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
||||
return voices
|
||||
|
||||
|
||||
@router.post("/speak")
|
||||
|
|
@ -21,15 +39,24 @@ def text_to_speech(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech using configured TTS model."""
|
||||
"""Convert text to speech using configured or user-selected TTS model."""
|
||||
if not request.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||
|
||||
# Limit text length
|
||||
text = request.text[:2000]
|
||||
|
||||
# Get TTS model config
|
||||
model_id, api_key = ai_service.get_model_for_task(db, "tts")
|
||||
if request.voice:
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
||||
model_id = config.model_id if config else request.voice
|
||||
api_key = config.api_key if (config and config.api_key) else None
|
||||
else:
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.is_default == True,
|
||||
).first()
|
||||
model_id = config.model_id if config else "tts-1:alloy"
|
||||
api_key = config.api_key if (config and config.api_key) else None
|
||||
|
||||
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
|
||||
if audio is None:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class AIModelConfigCreate(BaseModel):
|
|||
|
||||
|
||||
class AIModelConfigResponse(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
model_config = {"protected_namespaces": (), "from_attributes": True}
|
||||
id: int
|
||||
name: str
|
||||
model_id: str
|
||||
|
|
@ -23,9 +23,6 @@ class AIModelConfigResponse(BaseModel):
|
|||
is_default: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AIModelConfigUpdate(BaseModel):
|
||||
model_config = {"protected_namespaces": ()}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class AnswerDetail(BaseModel):
|
|||
question_id: int
|
||||
question_text: str
|
||||
question_type: str
|
||||
options: list[str] | None = None
|
||||
user_answer: str
|
||||
correct_answer: str
|
||||
is_correct: bool
|
||||
|
|
|
|||
|
|
@ -31,4 +31,19 @@ class LoginRequest(BaseModel):
|
|||
|
||||
|
||||
class UserUpdateRole(BaseModel):
|
||||
role: str # admin, moderator, user
|
||||
role: str
|
||||
|
||||
|
||||
class UserUpdateMe(BaseModel):
|
||||
name: str | None = None
|
||||
current_password: str | None = None
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class QuizCreate(BaseModel):
|
|||
title: str
|
||||
mode: str = "timed" # timed, learning
|
||||
time_limit_minutes: int | None = None
|
||||
model_id: str | None = None # override extraction model
|
||||
|
||||
|
||||
class QuestionResponse(BaseModel):
|
||||
|
|
@ -35,6 +36,7 @@ class QuizResponse(BaseModel):
|
|||
questions_count: int
|
||||
mode: str
|
||||
time_limit_minutes: int | None
|
||||
skipped_questions: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -7,28 +7,46 @@ from app.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXTRACTION_PROMPT = """You are a quiz question extractor. The following content is from a PDF that contains quiz/exam questions with answers and explanations.
|
||||
|
||||
Your job is to EXTRACT (not generate) all the questions, their options, correct answers, and explanations exactly as they appear in the content.
|
||||
def _proxy_model(model_id: str) -> str:
|
||||
"""Prefix model with openai/ if using a LiteLLM proxy and no provider is specified."""
|
||||
if settings.LITELLM_API_BASE and "/" not in model_id:
|
||||
return f"openai/{model_id}"
|
||||
return model_id
|
||||
|
||||
Return a JSON object with a "questions" key containing an array where each object has:
|
||||
- "question_text": the full question text as it appears
|
||||
- "question_type": "mcq" if it has multiple choice options, "true_false" if True/False, "fill_blank" if fill-in-the-blank
|
||||
- "options": array of option strings exactly as written (for mcq), ["True", "False"] (for true_false), or null (for fill_blank)
|
||||
- "correct_answer": the correct answer exactly as marked in the source
|
||||
- "explanation": the explanation text if provided, or "" if none
|
||||
- "page_reference": {page_ref}
|
||||
EXTRACTION_PROMPT = """You are extracting questions from a PREP (Pediatric Review and Education Program) exam PDF.
|
||||
|
||||
Important:
|
||||
- Extract ALL questions found in the content — do not skip any
|
||||
- Preserve the original wording exactly
|
||||
- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be
|
||||
- If no clear correct answer is marked, use the best answer based on the explanation
|
||||
These PDFs follow a strict format:
|
||||
1. A numbered question with a clinical vignette (patient scenario)
|
||||
2. Five answer options labeled A, B, C, D, E
|
||||
3. A line "Correct Answer: X" where X is the letter of the correct option
|
||||
4. An explanation paragraph
|
||||
5. A "Critique:" section with detailed reasoning
|
||||
6. A "Content Specifications:" section listing the learning objectives
|
||||
|
||||
Your task: extract every question from the content below and return ONLY a JSON object in this exact format:
|
||||
|
||||
{{"questions": [
|
||||
{{
|
||||
"question_text": "<full question stem including any patient vignette>",
|
||||
"question_type": "mcq",
|
||||
"options": ["<option A text>", "<option B text>", "<option C text>", "<option D text>", "<option E text>"],
|
||||
"correct_answer": "<full text of the correct option, not just the letter>",
|
||||
"explanation": "<explanation paragraph>\\n\\nCritique: <critique section verbatim>\\n\\nContent Specifications: <content spec section verbatim>",
|
||||
"page_reference": {page_ref}
|
||||
}}
|
||||
]}}
|
||||
|
||||
Rules:
|
||||
- "Correct Answer: C" means option C is correct — store the full text of option C as correct_answer
|
||||
- If no "Correct Answer:" line appears after a question, set correct_answer to null
|
||||
- Preserve ALL text exactly as written — do not summarize or paraphrase
|
||||
- Include the Critique and Content Specifications verbatim — they are critical
|
||||
- Extract ALL questions in the content, even if partially cut off
|
||||
- Return ONLY the JSON — no markdown, no explanation, no preamble
|
||||
|
||||
Content from page(s) {page_info}:
|
||||
{content}
|
||||
|
||||
Return ONLY valid JSON. No markdown formatting."""
|
||||
{content}"""
|
||||
|
||||
|
||||
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
|
||||
|
|
@ -70,7 +88,7 @@ def extract_questions(
|
|||
page_ref=page_ref if page_ref else "null",
|
||||
)
|
||||
|
||||
use_model = model_id or settings.LITELLM_MODEL
|
||||
use_model = _proxy_model(model_id or settings.LITELLM_MODEL)
|
||||
use_key = api_key or settings.LITELLM_API_KEY
|
||||
|
||||
last_error = None
|
||||
|
|
@ -83,16 +101,14 @@ def extract_questions(
|
|||
}
|
||||
if use_key:
|
||||
kwargs["api_key"] = use_key
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
|
||||
# Try JSON mode if supported
|
||||
try:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
response = litellm.completion(**kwargs)
|
||||
except Exception:
|
||||
kwargs.pop("response_format", None)
|
||||
response = litellm.completion(**kwargs)
|
||||
# Don't force JSON mode — let the model respond naturally and we parse it
|
||||
response = litellm.completion(**kwargs)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
logger.info(f"Model raw response (first 500 chars): {response_text[:500]!r}")
|
||||
|
||||
# Try to parse JSON, handle markdown code blocks
|
||||
text = response_text.strip()
|
||||
|
|
@ -103,13 +119,33 @@ def extract_questions(
|
|||
text = text.strip()
|
||||
|
||||
data = json.loads(text)
|
||||
questions = data.get("questions", data) if isinstance(data, dict) else data
|
||||
if not isinstance(questions, list):
|
||||
|
||||
# Handle all common response shapes
|
||||
if isinstance(data, list):
|
||||
questions = data
|
||||
elif isinstance(data, dict):
|
||||
# Try common keys
|
||||
for key in ("questions", "items", "results", "data"):
|
||||
if isinstance(data.get(key), list):
|
||||
questions = data[key]
|
||||
break
|
||||
else:
|
||||
# Maybe the dict itself is a single question
|
||||
if "question_text" in data:
|
||||
questions = [data]
|
||||
else:
|
||||
raise ValueError(f"Unexpected response shape: {list(data.keys())}")
|
||||
else:
|
||||
raise ValueError("Response is not a list of questions")
|
||||
|
||||
validated = []
|
||||
skipped = []
|
||||
for q in questions:
|
||||
if not all(k in q for k in ("question_text", "correct_answer")):
|
||||
if "question_text" not in q:
|
||||
continue
|
||||
correct = q.get("correct_answer")
|
||||
if not correct:
|
||||
skipped.append(q.get("question_text", "")[:120])
|
||||
continue
|
||||
qtype = q.get("question_type", "mcq")
|
||||
if qtype not in ("mcq", "true_false", "fill_blank"):
|
||||
|
|
@ -118,11 +154,16 @@ def extract_questions(
|
|||
"question_text": q["question_text"],
|
||||
"question_type": qtype,
|
||||
"options": q.get("options"),
|
||||
"correct_answer": q["correct_answer"],
|
||||
"correct_answer": correct,
|
||||
"explanation": q.get("explanation", ""),
|
||||
"page_reference": q.get("page_reference"),
|
||||
"skipped": [],
|
||||
})
|
||||
|
||||
# Attach skipped list to first question so caller can surface it
|
||||
if validated and skipped:
|
||||
validated[0]["skipped"] = skipped
|
||||
|
||||
if validated:
|
||||
return validated
|
||||
|
||||
|
|
@ -130,7 +171,7 @@ def extract_questions(
|
|||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"Extraction attempt {attempt + 1} failed: {e}")
|
||||
logger.warning(f"Extraction attempt {attempt + 1} failed: {e!r}")
|
||||
|
||||
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
|
||||
|
||||
|
|
@ -140,23 +181,120 @@ def generate_tts_audio(
|
|||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Generate TTS audio using LiteLLM (supports Google Vertex, OpenAI, etc.)."""
|
||||
use_model = model_id or "vertex_ai/google/cloud-tts"
|
||||
use_key = api_key or settings.LITELLM_API_KEY
|
||||
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
|
||||
|
||||
model_id conventions:
|
||||
tts-1:alloy → OpenAI TTS (voice after colon)
|
||||
tts-1-hd:nova → OpenAI TTS HD
|
||||
elevenlabs/<voice> → ElevenLabs
|
||||
google/<voice_name> → Google Cloud TTS (e.g. google/en-US-Wavenet-D)
|
||||
polly/<VoiceId> → AWS Polly Neural (e.g. polly/Joanna)
|
||||
"""
|
||||
import httpx, base64
|
||||
|
||||
use_model = model_id or "tts-1:alloy"
|
||||
|
||||
# ── ElevenLabs ─────────────────────────────────────────────
|
||||
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
|
||||
voice = use_model.split("/", 1)[1]
|
||||
key = api_key or settings.ELEVENLABS_API_KEY
|
||||
if not key:
|
||||
logger.error("ElevenLabs API key not configured")
|
||||
return None
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"https://api.elevenlabs.io/v1/text-to-speech/{voice}",
|
||||
headers={"xi-api-key": key, "Content-Type": "application/json"},
|
||||
json={"text": text, "model_id": "eleven_turbo_v2_5"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"ElevenLabs TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── Google Cloud TTS ────────────────────────────────────────
|
||||
if use_model.startswith("google/"):
|
||||
voice_name = use_model[len("google/"):]
|
||||
key = api_key or settings.GOOGLE_TTS_API_KEY
|
||||
if not key:
|
||||
logger.error("Google TTS API key not configured (GOOGLE_TTS_API_KEY)")
|
||||
return None
|
||||
# Parse language code from voice name (e.g. "en-US-Wavenet-D" → "en-US")
|
||||
parts = voice_name.split("-")
|
||||
lang_code = f"{parts[0]}-{parts[1]}" if len(parts) >= 2 else "en-US"
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"https://texttospeech.googleapis.com/v1/text:synthesize?key={key}",
|
||||
json={
|
||||
"input": {"text": text},
|
||||
"voice": {"languageCode": lang_code, "name": voice_name},
|
||||
"audioConfig": {"audioEncoding": "MP3"},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return base64.b64decode(resp.json()["audioContent"])
|
||||
except Exception as e:
|
||||
logger.error(f"Google TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── AWS Polly ───────────────────────────────────────────────
|
||||
if use_model.startswith("polly/"):
|
||||
voice_id = use_model[len("polly/"):]
|
||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
||||
secret_key = settings.AWS_SECRET_ACCESS_KEY
|
||||
region = settings.AWS_REGION or "us-east-1"
|
||||
if not access_key or not secret_key:
|
||||
logger.error("AWS credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
|
||||
return None
|
||||
try:
|
||||
import boto3
|
||||
polly = boto3.client(
|
||||
"polly",
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
)
|
||||
response = polly.synthesize_speech(
|
||||
Text=text,
|
||||
OutputFormat="mp3",
|
||||
VoiceId=voice_id,
|
||||
Engine="neural",
|
||||
)
|
||||
return response["AudioStream"].read()
|
||||
except Exception as e:
|
||||
logger.error(f"AWS Polly TTS failed: {e}")
|
||||
return None
|
||||
|
||||
# ── OpenAI (default) ────────────────────────────────────────
|
||||
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
|
||||
clean_model = use_model.replace("openai/", "")
|
||||
oai_voice = "alloy"
|
||||
if ":" in clean_model:
|
||||
clean_model, oai_voice = clean_model.split(":", 1)
|
||||
|
||||
# Per-model key > OPENAI_API_KEY (direct) > LITELLM_API_KEY (proxy)
|
||||
if api_key:
|
||||
key = api_key
|
||||
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/")
|
||||
elif settings.OPENAI_API_KEY:
|
||||
key = settings.OPENAI_API_KEY
|
||||
base = "https://api.openai.com"
|
||||
else:
|
||||
key = settings.LITELLM_API_KEY
|
||||
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/")
|
||||
|
||||
try:
|
||||
# LiteLLM speech endpoint
|
||||
response = litellm.speech(
|
||||
model=use_model,
|
||||
input=text,
|
||||
api_key=use_key if use_key else None,
|
||||
resp = httpx.post(
|
||||
f"{base}/v1/audio/speech",
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
json={"model": clean_model, "input": text, "voice": oai_voice},
|
||||
timeout=60,
|
||||
)
|
||||
# Response is audio bytes
|
||||
if hasattr(response, "content"):
|
||||
return response.content
|
||||
if hasattr(response, "read"):
|
||||
return response.read()
|
||||
return bytes(response)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"TTS generation failed: {e}")
|
||||
logger.error(f"OpenAI TTS failed: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ def get_mail_config() -> ConnectionConfig:
|
|||
MAIL_USERNAME=settings.MAIL_USERNAME,
|
||||
MAIL_PASSWORD=settings.MAIL_PASSWORD,
|
||||
MAIL_FROM=settings.MAIL_FROM,
|
||||
MAIL_FROM_NAME="PedQuiz",
|
||||
MAIL_PORT=settings.MAIL_PORT,
|
||||
MAIL_SERVER=settings.MAIL_SERVER,
|
||||
MAIL_STARTTLS=settings.MAIL_STARTTLS,
|
||||
|
|
@ -20,45 +23,152 @@ def get_mail_config() -> ConnectionConfig:
|
|||
)
|
||||
|
||||
|
||||
async def send_reminder_email(
|
||||
email: str,
|
||||
user_name: str,
|
||||
quiz_title: str,
|
||||
score: float,
|
||||
next_date: str,
|
||||
):
|
||||
"""Send a spaced-repetition reminder email."""
|
||||
html = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">Quiz Reminder</h2>
|
||||
<p>Hi {user_name},</p>
|
||||
<p>It's time to review <strong>{quiz_title}</strong>!</p>
|
||||
<p>Your last score was <strong>{score:.0f}%</strong>.
|
||||
{'Great progress! Keep it up.' if score >= 70 else 'Practice makes perfect — give it another try!'}</p>
|
||||
<div style="background: #f3f4f6; padding: 16px; border-radius: 8px; margin: 16px 0;">
|
||||
<p style="margin: 0;"><strong>Tip:</strong> Spaced repetition helps you remember more over time.
|
||||
Each review strengthens your memory!</p>
|
||||
</div>
|
||||
<p>Log in to take the quiz again.</p>
|
||||
<p style="color: #6b7280; font-size: 12px;">
|
||||
You're receiving this because you have active quiz reminders.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
def _render(md: str) -> str:
|
||||
"""
|
||||
Minimal markdown → inline HTML for email.
|
||||
Resend-style: white background, dark text, clean sans-serif, one column.
|
||||
"""
|
||||
lines, out = md.strip().split("\n"), []
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
# Bold inline
|
||||
s_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
|
||||
|
||||
message = MessageSchema(
|
||||
subject=f"Quiz Reminder: {quiz_title}",
|
||||
recipients=[email],
|
||||
body=html,
|
||||
subtype=MessageType.html,
|
||||
)
|
||||
if s.startswith("# "):
|
||||
out.append(f'<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#09090b;letter-spacing:-0.4px;line-height:1.2;">{s[2:]}</h1>')
|
||||
elif s.startswith("## "):
|
||||
out.append(f'<h2 style="margin:24px 0 8px;font-size:15px;font-weight:600;color:#18181b;text-transform:uppercase;letter-spacing:0.05em;">{s[3:]}</h2>')
|
||||
elif s.startswith("> "):
|
||||
out.append(f'<blockquote style="margin:16px 0;padding:12px 16px;background:#f4f4f5;border-left:3px solid #d4d4d8;border-radius:0 6px 6px 0;font-size:13px;color:#71717a;line-height:1.6;">{s_html[2:]}</blockquote>')
|
||||
elif s.startswith("---"):
|
||||
out.append('<hr style="border:none;border-top:1px solid #f4f4f5;margin:28px 0;"/>')
|
||||
elif s.startswith("[button:"):
|
||||
m = re.match(r'\[button:(.+?)\]\((.+?)\)', s)
|
||||
if m:
|
||||
label, url = m.group(1), m.group(2)
|
||||
out.append(f'<p style="margin:28px 0;"><a href="{url}" style="display:inline-block;background:#09090b;color:#fafafa;font-size:14px;font-weight:500;padding:10px 24px;border-radius:6px;text-decoration:none;letter-spacing:0.01em;">{label} →</a></p>')
|
||||
elif s.startswith("[link:"):
|
||||
m = re.match(r'\[link:(.+?)\]\((.+?)\)', s)
|
||||
if m:
|
||||
label, url = m.group(1), m.group(2)
|
||||
out.append(f'<p style="margin:4px 0;font-size:12px;color:#a1a1aa;">Or copy: <a href="{url}" style="color:#71717a;text-decoration:underline;word-break:break-all;">{url}</a></p>')
|
||||
elif s == "":
|
||||
out.append('<div style="height:10px;"></div>')
|
||||
else:
|
||||
out.append(f'<p style="margin:0 0 12px;font-size:14px;color:#3f3f46;line-height:1.7;">{s_html}</p>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _wrap(subject: str, body_md: str) -> str:
|
||||
year = datetime.utcnow().year
|
||||
body_html = _render(body_md)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>{subject}</title></head>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr><td align="center" style="padding:48px 20px 32px;">
|
||||
<table width="540" cellpadding="0" cellspacing="0" style="max-width:540px;width:100%;">
|
||||
|
||||
<!-- Logo -->
|
||||
<tr><td style="padding-bottom:36px;">
|
||||
<span style="font-size:15px;font-weight:600;color:#09090b;letter-spacing:-0.2px;">🩺 PedQuiz</span>
|
||||
</td></tr>
|
||||
|
||||
<!-- Card -->
|
||||
<tr><td style="background:#ffffff;border:1px solid #e4e4e7;border-radius:8px;padding:40px;">
|
||||
{body_html}
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding:24px 0 0;">
|
||||
<p style="margin:0;font-size:12px;color:#a1a1aa;line-height:1.6;">
|
||||
PedQuiz · Pediatric Knowledge Platform<br/>
|
||||
<a href="{settings.APP_URL}" style="color:#a1a1aa;text-decoration:underline;">{settings.APP_URL}</a>
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">
|
||||
Didn't expect this email? You can safely ignore it.
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
async def _send(to_email: str, subject: str, html: str):
|
||||
if not settings.MAIL_USERNAME or not settings.MAIL_FROM:
|
||||
logger.info(f"[EMAIL not configured] To:{to_email} Subject:{subject}")
|
||||
return
|
||||
try:
|
||||
conf = get_mail_config()
|
||||
fm = FastMail(conf)
|
||||
await fm.send_message(message)
|
||||
logger.info(f"Reminder sent to {email} for quiz '{quiz_title}'")
|
||||
fm = FastMail(get_mail_config())
|
||||
await fm.send_message(MessageSchema(subject=subject, recipients=[to_email], body=html, subtype=MessageType.html))
|
||||
logger.info(f"Email sent → {to_email}: {subject}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reminder to {email}: {e}")
|
||||
logger.error(f"Email failed → {to_email}: {e}")
|
||||
|
||||
|
||||
async def send_verification_email(to_email: str, name: str, token: str):
|
||||
url = f"{settings.APP_URL}/verify-email?token={token}"
|
||||
subject = "Verify your PedQuiz email"
|
||||
md = f"""# Verify your email
|
||||
|
||||
Hi **{name}**,
|
||||
|
||||
Welcome to PedQuiz. Click below to verify your email address and activate your account.
|
||||
|
||||
[button:Verify Email Address]({url})
|
||||
|
||||
> This link expires in **24 hours**.
|
||||
|
||||
---
|
||||
|
||||
[link:Or copy this link]({url})
|
||||
"""
|
||||
await _send(to_email, subject, _wrap(subject, md))
|
||||
|
||||
|
||||
async def send_password_reset_email(to_email: str, name: str, token: str):
|
||||
url = f"{settings.APP_URL}/reset-password?token={token}"
|
||||
subject = "Reset your PedQuiz password"
|
||||
md = f"""# Reset your password
|
||||
|
||||
Hi **{name}**,
|
||||
|
||||
We received a request to reset your password. Click below to choose a new one.
|
||||
|
||||
[button:Reset Password]({url})
|
||||
|
||||
> Expires in **1 hour** · Single use only. If you didn't request this, ignore this email — your account is safe.
|
||||
|
||||
---
|
||||
|
||||
[link:Or copy this link]({url})
|
||||
"""
|
||||
await _send(to_email, subject, _wrap(subject, md))
|
||||
|
||||
|
||||
async def send_reminder_email(email: str, user_name: str, quiz_title: str, score: float, next_date: str):
|
||||
subject = f"Time to review: {quiz_title}"
|
||||
note = "Great work — keep the streak going! 🌟" if score >= 75 else "A bit more practice will get you there. 📚"
|
||||
md = f"""# Time to review
|
||||
|
||||
Hi **{user_name}**,
|
||||
|
||||
Your spaced repetition schedule says it's time to revisit **{quiz_title}**.
|
||||
|
||||
## Last score
|
||||
|
||||
**{score:.0f}%** — {note}
|
||||
|
||||
Reviewing at spaced intervals is one of the most effective ways to retain medical knowledge long-term. Each session strengthens the memory trace.
|
||||
|
||||
[button:Take Quiz Now]({settings.APP_URL}/quizzes)
|
||||
|
||||
---
|
||||
|
||||
You're receiving this because you have active quiz reminders. To stop, disable reminders in your account settings.
|
||||
"""
|
||||
await _send(email, subject, _wrap(subject, md))
|
||||
|
|
|
|||
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.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.services import ai_service, vector_service, pdf_service
|
||||
from app.services import ai_service, vector_service, pdf_service, embedding_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ def create_quiz_from_section(
|
|||
title: str,
|
||||
mode: str = "timed",
|
||||
time_limit_minutes: int | None = None,
|
||||
model_id: str | None = None,
|
||||
) -> Quiz:
|
||||
"""Extract questions from a section's page range using AI."""
|
||||
section = db.query(Section).filter(Section.id == section_id).first()
|
||||
|
|
@ -40,8 +41,13 @@ def create_quiz_from_section(
|
|||
if not content:
|
||||
raise ValueError("No content found for this section's page range")
|
||||
|
||||
# Get configured model
|
||||
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
|
||||
# Get configured model (use override if provided)
|
||||
if model_id:
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||
api_key = config.api_key if config and config.api_key else None
|
||||
else:
|
||||
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
|
||||
|
||||
# Extract questions via AI (not generate — questions already exist in PDF)
|
||||
page_info = f"{section.start_page}-{section.end_page}"
|
||||
|
|
@ -63,20 +69,28 @@ def create_quiz_from_section(
|
|||
except Exception as e:
|
||||
logger.warning(f"Image extraction failed: {e}")
|
||||
|
||||
# Collect skipped questions (those without a correct answer)
|
||||
import json
|
||||
skipped = []
|
||||
if question_data and question_data[0].get("skipped"):
|
||||
skipped = question_data[0].pop("skipped")
|
||||
valid_questions = [q for q in question_data if q.get("correct_answer")]
|
||||
|
||||
# Create quiz
|
||||
quiz = Quiz(
|
||||
section_id=section_id,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
questions_count=len(question_data),
|
||||
questions_count=len(valid_questions),
|
||||
mode=mode,
|
||||
time_limit_minutes=time_limit_minutes,
|
||||
skipped_questions=json.dumps(skipped) if skipped else None,
|
||||
)
|
||||
db.add(quiz)
|
||||
db.flush()
|
||||
|
||||
# Create question records, associating images where possible
|
||||
for q in question_data:
|
||||
for q in valid_questions:
|
||||
page_ref = q.get("page_reference")
|
||||
image_path = None
|
||||
|
||||
|
|
@ -98,6 +112,11 @@ def create_quiz_from_section(
|
|||
image_path=image_path,
|
||||
)
|
||||
db.add(question)
|
||||
db.flush()
|
||||
try:
|
||||
embedding_service.embed_question(question)
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding generation failed for question {question.id}: {e}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(quiz)
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ def update_reminder_schedule(
|
|||
current_idx = i
|
||||
break
|
||||
|
||||
if score_percentage < 70:
|
||||
# Poor performance: reset to shortest interval
|
||||
if score_percentage < 75:
|
||||
# Below threshold: reset to shortest interval
|
||||
new_idx = 0
|
||||
elif score_percentage < 90:
|
||||
# Decent performance: advance one step
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import chromadb
|
||||
from chromadb import EmbeddingFunction, Embeddings
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
class LiteLLMEmbeddingFunction(EmbeddingFunction):
|
||||
"""ChromaDB embedding function backed by LiteLLM."""
|
||||
|
||||
def __call__(self, input: list[str]) -> Embeddings:
|
||||
import litellm
|
||||
kwargs = {
|
||||
"model": settings.LITELLM_EMBEDDING_MODEL,
|
||||
"input": input,
|
||||
}
|
||||
if settings.LITELLM_API_KEY:
|
||||
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
response = litellm.embedding(**kwargs)
|
||||
return [item["embedding"] for item in response.data]
|
||||
|
||||
|
||||
def get_client() -> chromadb.PersistentClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
|
|
@ -14,7 +32,13 @@ def get_client() -> chromadb.PersistentClient:
|
|||
|
||||
def get_or_create_collection(document_id: int):
|
||||
client = get_client()
|
||||
return client.get_or_create_collection(name=f"doc_{document_id}")
|
||||
# Only use custom embedding if model is configured and has a provider prefix
|
||||
use_ef = bool(settings.LITELLM_EMBEDDING_MODEL and "/" in settings.LITELLM_EMBEDDING_MODEL)
|
||||
ef = LiteLLMEmbeddingFunction() if use_ef else None
|
||||
kwargs = {"name": f"doc_{document_id}"}
|
||||
if ef:
|
||||
kwargs["embedding_function"] = ef
|
||||
return client.get_or_create_collection(**kwargs)
|
||||
|
||||
|
||||
def delete_collection(document_id: int):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ celery_app = Celery(
|
|||
"quiz_tasks",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.pdf_tasks"],
|
||||
)
|
||||
celery_app.conf.task_serializer = "json"
|
||||
celery_app.conf.result_serializer = "json"
|
||||
|
|
|
|||
100
backend/manage.py
Normal file
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
|
||||
psycopg2-binary==2.9.9
|
||||
uvicorn[standard]==0.27.1
|
||||
sqlalchemy==2.0.27
|
||||
alembic==1.13.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
pydantic[email]==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
|
|
@ -17,3 +19,5 @@ apscheduler==3.10.4
|
|||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.0
|
||||
boto3==1.34.69
|
||||
pgvector==0.3.6
|
||||
|
|
|
|||
|
|
@ -1,28 +1,41 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: pedquiz
|
||||
POSTGRES_USER: pedquiz
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pedquiz"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "127.0.0.1:8081:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- chroma_data:/app/chroma_data
|
||||
- sqlite_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
||||
depends_on:
|
||||
- redis
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
celery:
|
||||
|
|
@ -33,11 +46,11 @@ services:
|
|||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- chroma_data:/app/chroma_data
|
||||
- sqlite_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/quiz.db
|
||||
depends_on:
|
||||
- redis
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
|
|
@ -49,5 +62,5 @@ services:
|
|||
volumes:
|
||||
uploads_data:
|
||||
chroma_data:
|
||||
sqlite_data:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PDF Quiz Generator</title>
|
||||
<title>PedQuiz</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🩺</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ server {
|
|||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $backend http://backend:8000;
|
||||
proxy_pass $backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
|
|
@ -10,6 +11,12 @@ import QuizPage from './pages/QuizPage'
|
|||
import QuizzesPage from './pages/QuizzesPage'
|
||||
import ResultsPage from './pages/ResultsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import AccountPage from './pages/AccountPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import QuizEditPage from './pages/QuizEditPage'
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
|
||||
function ProtectedRoute({ children, requireModerator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
|
@ -19,6 +26,16 @@ function ProtectedRoute({ children, requireModerator = false }) {
|
|||
return children
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="container">
|
||||
© {new Date().getFullYear()} PedQuiz
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
||||
|
|
@ -30,6 +47,9 @@ function AppRoutes() {
|
|||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
|
||||
|
|
@ -37,8 +57,12 @@ function AppRoutes() {
|
|||
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
|
||||
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes/:id/edit" element={<ProtectedRoute requireModerator><QuizEditPage /></ProtectedRoute>} />
|
||||
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -46,9 +70,11 @@ function AppRoutes() {
|
|||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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 (
|
||||
<div className="navbar">
|
||||
<div className="container">
|
||||
<Link to="/" className="logo">PDF Quiz</Link>
|
||||
<Link to="/" className="logo">🩺 PedQuiz</Link>
|
||||
{user && (
|
||||
<nav>
|
||||
<Link to="/">Dashboard</Link>
|
||||
<Link to="/quizzes">Quizzes</Link>
|
||||
{isModerator && <Link to="/upload">Upload PDF</Link>}
|
||||
{user.role === 'admin' && <Link to="/admin" style={{ color: '#fbbf24' }}>Admin</Link>}
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
|
||||
{user.name}
|
||||
{user.role !== 'user' && (
|
||||
<span style={{
|
||||
marginLeft: 6, fontSize: '0.7rem',
|
||||
background: user.role === 'admin' ? '#ef4444' : '#7c3aed',
|
||||
color: 'white', padding: '1px 6px', borderRadius: 10
|
||||
}}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Link to="/settings" title="Settings">⚙ Settings</Link>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</nav>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,8 @@ export function AuthProvider({ children }) {
|
|||
return me.data
|
||||
}
|
||||
|
||||
const register = async (email, password, name) => {
|
||||
const res = await api.post('/auth/register', { email, password, name })
|
||||
localStorage.setItem('token', res.data.access_token)
|
||||
const loginWithToken = async (token) => {
|
||||
localStorage.setItem('token', token)
|
||||
const me = await api.get('/auth/me')
|
||||
setUser(me.data)
|
||||
return me.data
|
||||
|
|
@ -41,7 +40,7 @@ export function AuthProvider({ children }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, loginWithToken, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
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 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
/* ── CSS Variables (themes) ─────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #f1f5f9;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: 1px solid transparent;
|
||||
--card-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
--card-radius: 12px;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--primary-fg: #ffffff;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--text-subtle: #94a3b8;
|
||||
--border: #e2e8f0;
|
||||
--input-bg: #ffffff;
|
||||
--option-bg: #ffffff;
|
||||
--option-hover: #f8fafc;
|
||||
--option-sel-bg: #eff6ff;
|
||||
--option-sel-bd: #2563eb;
|
||||
--correct-bg: #d1fae5;
|
||||
--correct-bd: #86efac;
|
||||
--correct-fg: #14532d;
|
||||
--wrong-bg: #fee2e2;
|
||||
--wrong-bd: #fca5a5;
|
||||
--wrong-fg: #7f1d1d;
|
||||
--expl-bg: #f0f9ff;
|
||||
--expl-bd: #38bdf8;
|
||||
--navbar-bg: #0f172a;
|
||||
--navbar-fg: #e2e8f0;
|
||||
--badge-radius: 12px;
|
||||
}
|
||||
|
||||
[data-theme="markdown"] {
|
||||
--bg: #eaeef2;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: 1px solid #d0d7de;
|
||||
--card-shadow: none;
|
||||
--card-radius: 6px;
|
||||
--primary: #0969da;
|
||||
--primary-hover: #0757ba;
|
||||
--primary-fg: #ffffff;
|
||||
--text: #1f2328;
|
||||
--text-muted: #57606a;
|
||||
--text-subtle: #8c959f;
|
||||
--border: #d0d7de;
|
||||
--input-bg: #f6f8fa;
|
||||
--option-bg: #ffffff;
|
||||
--option-hover: #f6f8fa;
|
||||
--option-sel-bg: #ddf4ff;
|
||||
--option-sel-bd: #0969da;
|
||||
--correct-bg: #dafbe1;
|
||||
--correct-bd: #82cfb5;
|
||||
--correct-fg: #116329;
|
||||
--wrong-bg: #ffebe9;
|
||||
--wrong-bd: #ff9d8a;
|
||||
--wrong-fg: #82071e;
|
||||
--expl-bg: #f6f8fa;
|
||||
--expl-bd: #0969da;
|
||||
--navbar-bg: #24292f;
|
||||
--navbar-fg: #e6edf3;
|
||||
--badge-radius: 2em;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||
--font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Markdown theme typography overrides ───────────────────── */
|
||||
body[data-theme="markdown"] {
|
||||
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
||||
font-size: 16px;
|
||||
}
|
||||
[data-theme="markdown"] .card h2,
|
||||
[data-theme="markdown"] h1,
|
||||
[data-theme="markdown"] h2,
|
||||
[data-theme="markdown"] h3 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
[data-theme="markdown"] .question-card h3 { border-bottom: none; padding-bottom: 0; font-size: 1.1rem; }
|
||||
[data-theme="markdown"] .question-card { border-left: 4px solid var(--primary); }
|
||||
[data-theme="markdown"] .explanation {
|
||||
font-family: var(--font-body); line-height: 1.8;
|
||||
border: 1px solid var(--border); border-left: 4px solid var(--expl-bd);
|
||||
background: var(--expl-bg);
|
||||
}
|
||||
[data-theme="markdown"] .option { border-radius: 6px; font-size: 0.95rem; }
|
||||
[data-theme="markdown"] .option-letter { border-radius: 3px; font-family: monospace; }
|
||||
[data-theme="markdown"] .card { border-radius: 6px; }
|
||||
[data-theme="markdown"] .btn { font-size: 0.875rem; }
|
||||
[data-theme="markdown"] .review-card { border-radius: 6px; }
|
||||
[data-theme="markdown"] .score-display .score-value { letter-spacing: -3px; }
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
transition: background 0.25s, color 0.25s;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 20px; }
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
/* ── Navbar ─────────────────────────────────────────────────── */
|
||||
.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 12px 0; margin-bottom: 28px; }
|
||||
.navbar .container { display: flex; justify-content: space-between; align-items: center; }
|
||||
.navbar .logo { font-size: 1.2rem; font-weight: 700; color: #60a5fa; text-decoration: none; }
|
||||
[data-theme="minimal"] .navbar .logo { color: #a1a1aa; }
|
||||
.navbar nav { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.88rem; opacity: 0.75; }
|
||||
.navbar a:hover { opacity: 1; }
|
||||
.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.18); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; }
|
||||
|
||||
.navbar .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar .logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.navbar a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar button {
|
||||
background: transparent;
|
||||
border: 1px solid #475569;
|
||||
color: #cbd5e1;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* ── Cards ──────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 28px;
|
||||
box-shadow: var(--card-shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
/* ── Buttons ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 20px; border-radius: 8px; border: none;
|
||||
cursor: pointer; font-size: 0.875rem; font-weight: 500;
|
||||
text-decoration: none; transition: background 0.15s, opacity 0.15s; white-space: nowrap;
|
||||
}
|
||||
[data-theme="minimal"] .btn { border-radius: 6px; }
|
||||
.btn-primary { background: var(--primary); color: var(--primary-fg); }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-secondary { background: var(--border); color: var(--text); }
|
||||
.btn-secondary:hover { filter: brightness(0.94); }
|
||||
.btn-danger { background: #ef4444; color: white; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-sm { padding: 5px 12px; font-size: 0.78rem; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-weight: 500; margin-bottom: 6px; font-size: 0.85rem; color: var(--text-muted); }
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%; padding: 9px 13px;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 0.9rem; background: var(--input-bg); color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
[data-theme="minimal"] .form-group input,
|
||||
[data-theme="minimal"] .form-group select,
|
||||
[data-theme="minimal"] .form-group textarea { border-radius: 6px; background: var(--input-bg); }
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
|
||||
[data-theme="minimal"] .form-group input:focus { box-shadow: 0 0 0 2px #09090b18; }
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-processing { background: #fef3c7; color: #92400e; }
|
||||
.badge-ready { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Quiz question styles */
|
||||
/* ── Question card (quiz-taking) ────────────────────────────── */
|
||||
.question-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid #2563eb;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.question-card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-card .options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-left: 4px solid var(--primary);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 28px 28px 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Options */
|
||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
||||
.question-card .option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
||||
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
|
||||
}
|
||||
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
||||
.question-card .option.selected { border-color: var(--option-sel-bd); background: var(--option-sel-bg); }
|
||||
.question-card .option.correct { border-color: var(--correct-bd); background: var(--correct-bg); color: var(--correct-fg); }
|
||||
.question-card .option.incorrect { border-color: var(--wrong-bd); background: var(--wrong-bg); color: var(--wrong-fg); }
|
||||
|
||||
.question-card .option:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.question-card .option.selected {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.question-card .option.correct {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.question-card .option.incorrect {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.question-card input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
.option-letter {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 27px; height: 27px; border-radius: 50%; flex-shrink: 0;
|
||||
font-size: 0.75rem; font-weight: 700; background: var(--border); color: var(--text-muted);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.option.selected .option-letter { background: var(--primary); color: white; }
|
||||
.option.correct .option-letter { background: #16a34a; color: white; }
|
||||
.option.incorrect .option-letter { background: #dc2626; color: white; }
|
||||
|
||||
/* Explanation */
|
||||
.explanation {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
border-left: 3px solid #60a5fa;
|
||||
margin-top: 20px; padding: 18px 20px;
|
||||
background: var(--expl-bg); border-left: 3px solid var(--expl-bd);
|
||||
border-radius: 8px; font-size: 0.875rem; color: var(--text-muted);
|
||||
line-height: 1.75; white-space: pre-line;
|
||||
}
|
||||
.explanation strong { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
/* ── Review card (results) ──────────────────────────────────── */
|
||||
.review-card {
|
||||
background: var(--card-bg); border: var(--card-border);
|
||||
border-radius: var(--card-radius); padding: 28px 28px 24px;
|
||||
margin-bottom: 20px; box-shadow: var(--card-shadow);
|
||||
border-left: 4px solid var(--border);
|
||||
}
|
||||
.review-card.correct-card { border-left-color: #22c55e; }
|
||||
.review-card.wrong-card { border-left-color: #ef4444; }
|
||||
.review-card.skipped-card { border-left-color: var(--text-subtle); }
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
/* ── Badges ─────────────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: var(--badge-radius); font-size: 0.72rem; font-weight: 600; }
|
||||
.badge-processing { background: #fef3c7; color: #92400e; }
|
||||
.badge-ready { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
}
|
||||
/* ── Stats ──────────────────────────────────────────────────── */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-card { background: var(--card-bg); border: var(--card-border); padding: 24px; border-radius: var(--card-radius); box-shadow: var(--card-shadow); text-align: center; }
|
||||
.stat-card .stat-value { font-size: 2.2rem; font-weight: 800; color: var(--primary); }
|
||||
.stat-card .stat-label { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* ── Grid ───────────────────────────────────────────────────── */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
/* Grid layouts */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
/* Score display */
|
||||
.score-display {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.score-display .score-value {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
/* ── Alerts ─────────────────────────────────────────────────── */
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 0.875rem; }
|
||||
.alert-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.alert-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
|
||||
/* ── Score display ──────────────────────────────────────────── */
|
||||
.score-display { text-align: center; padding: 40px 24px; }
|
||||
.score-display .score-value { font-size: 4.5rem; font-weight: 900; line-height: 1; letter-spacing: -2px; }
|
||||
.score-display .score-value.good { color: #22c55e; }
|
||||
.score-display .score-value.ok { color: #f59e0b; }
|
||||
.score-display .score-value.ok { color: #f59e0b; }
|
||||
.score-display .score-value.poor { color: #ef4444; }
|
||||
|
||||
/* Upload area */
|
||||
.upload-area {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
/* ── Upload ─────────────────────────────────────────────────── */
|
||||
.upload-area { border: 2px dashed var(--border); border-radius: 12px; padding: 56px; text-align: center; cursor: pointer; transition: all 0.2s; }
|
||||
.upload-area:hover, .upload-area.dragging { border-color: var(--primary); background: var(--option-sel-bg); }
|
||||
|
||||
/* ── Progress bar ───────────────────────────────────────────── */
|
||||
.progress-bar { background: var(--border); border-radius: 999px; height: 6px; overflow: hidden; }
|
||||
.progress-bar .fill { background: var(--primary); height: 100%; border-radius: 999px; transition: width 0.3s; }
|
||||
|
||||
/* ── Spinner ────────────────────────────────────────────────── */
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Section items ──────────────────────────────────────────── */
|
||||
.section-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: var(--card-bg); }
|
||||
|
||||
/* ── Auth pages ─────────────────────────────────────────────── */
|
||||
.auth-page { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
|
||||
.auth-card { background: var(--card-bg); padding: 40px; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,0.08); width: 100%; max-width: 420px; border: var(--card-border); }
|
||||
.auth-card h1 { text-align: center; margin-bottom: 24px; color: var(--text); }
|
||||
.auth-card .auth-link { text-align: center; margin-top: 14px; font-size: 0.875rem; color: var(--text-muted); }
|
||||
.auth-card .auth-link a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* ── Loading / empty ────────────────────────────────────────── */
|
||||
.loading { text-align: center; padding: 56px; color: var(--text-muted); }
|
||||
.empty-state { text-align: center; padding: 48px; color: var(--text-subtle); }
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────── */
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 24px 0 32px;
|
||||
margin-top: 48px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #2563eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-area.dragging {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
background: #2563eb;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Section list */
|
||||
.section-item {
|
||||
/* ── Quiz page bottom spacing ───────────────────────────────── */
|
||||
.quiz-bottom { padding-bottom: 48px; }
|
||||
.quiz-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.auth-card .auth-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-card .auth-link a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #94a3b8;
|
||||
gap: 8px;
|
||||
}
|
||||
.quiz-nav-numbers {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
.question-card, .review-card { padding: 20px 16px; }
|
||||
.card { padding: 20px; }
|
||||
.quiz-nav { flex-wrap: wrap; }
|
||||
.quiz-nav-numbers { order: 3; width: 100%; margin-top: 8px; }
|
||||
}
|
||||
|
|
|
|||
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'
|
||||
|
||||
const TASKS = ['extraction', 'tts', 'general']
|
||||
const PRESET_MODELS = {
|
||||
extraction: [
|
||||
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4o', model_id: 'gpt-4o' },
|
||||
{ name: 'Claude Sonnet 4.6', model_id: 'anthropic/claude-sonnet-4-6-20250514' },
|
||||
{ name: 'Claude Haiku 4.5', model_id: 'anthropic/claude-haiku-4-5-20251001' },
|
||||
{ name: 'Gemini Pro', model_id: 'gemini/gemini-1.5-pro' },
|
||||
],
|
||||
tts: [
|
||||
{ name: 'Google Vertex TTS', model_id: 'vertex_ai/google/cloud-tts' },
|
||||
{ name: 'OpenAI TTS-1', model_id: 'openai/tts-1' },
|
||||
{ name: 'OpenAI TTS-1 HD', model_id: 'openai/tts-1-hd' },
|
||||
],
|
||||
general: [
|
||||
{ name: 'GPT-4o Mini', model_id: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4o', model_id: 'gpt-4o' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -34,6 +16,15 @@ export default function AdminPage() {
|
|||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
|
||||
|
||||
// LiteLLM search state
|
||||
const [searchApiKey, setSearchApiKey] = useState('')
|
||||
const [searchApiBase, setSearchApiBase] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
||||
|
|
@ -108,8 +99,28 @@ export default function AdminPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const applyPreset = (preset) => {
|
||||
setNewModel(m => ({ ...m, name: preset.name, model_id: preset.model_id }))
|
||||
const searchLiteLLM = async () => {
|
||||
setSearchError('')
|
||||
setSearchResults([])
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (searchApiKey) params.set('api_key', searchApiKey)
|
||||
if (searchApiBase) params.set('api_base', searchApiBase)
|
||||
const res = await api.get(`/admin/litellm/models?${params}`)
|
||||
setSearchResults(res.data.models)
|
||||
} catch (err) {
|
||||
setSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addFromSearch = (modelId) => {
|
||||
setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
|
||||
setSearchResults([])
|
||||
// scroll to add form
|
||||
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
|
@ -119,6 +130,10 @@ export default function AdminPage() {
|
|||
return acc
|
||||
}, {})
|
||||
|
||||
const filteredResults = searchFilter
|
||||
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: searchResults
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
|
|
@ -137,13 +152,83 @@ export default function AdminPage() {
|
|||
|
||||
{tab === 'models' && (
|
||||
<>
|
||||
{/* LiteLLM search */}
|
||||
<div className="card">
|
||||
<h2>Search Models from LiteLLM</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
Query an OpenAI-compatible API (or your LiteLLM proxy) to see available models, then click one to add it.
|
||||
</p>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>API Base URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchApiBase}
|
||||
onChange={e => setSearchApiBase(e.target.value)}
|
||||
placeholder="e.g. https://api.openai.com or LiteLLM proxy URL"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API Key (optional — uses .env if blank)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={searchApiKey}
|
||||
onChange={e => setSearchApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
|
||||
{searchLoading ? 'Searching...' : 'Search Models'}
|
||||
</button>
|
||||
|
||||
{searchError && <div className="alert alert-error" style={{ marginTop: 12 }}>{searchError}</div>}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong>{searchResults.length} models found</strong>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||||
{filteredResults.map(modelId => (
|
||||
<div
|
||||
key={modelId}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '8px 12px', borderBottom: '1px solid #f1f5f9',
|
||||
fontSize: '0.85rem', fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<span>{modelId}</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => addFromSearch(modelId)}
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configured models by task */}
|
||||
{TASKS.map(task => (
|
||||
<div className="card" key={task}>
|
||||
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
|
||||
{task} Models
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
||||
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
|
||||
task === 'tts' ? '— Text-to-speech for reading questions' :
|
||||
task === 'tts' ? '— Text-to-speech voices' :
|
||||
'— General purpose AI'}
|
||||
</span>
|
||||
</h2>
|
||||
|
|
@ -173,7 +258,8 @@ export default function AdminPage() {
|
|||
</div>
|
||||
))}
|
||||
|
||||
<div className="card">
|
||||
{/* Add model form */}
|
||||
<div className="card" id="add-model-form">
|
||||
<h2>Add Model</h2>
|
||||
<form onSubmit={createModel}>
|
||||
<div className="grid-2">
|
||||
|
|
@ -183,15 +269,6 @@ export default function AdminPage() {
|
|||
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Preset (optional)</label>
|
||||
<select onChange={e => { if (e.target.value) applyPreset(JSON.parse(e.target.value)) }}>
|
||||
<option value="">— select preset —</option>
|
||||
{(PRESET_MODELS[newModel.task] || []).map(p => (
|
||||
<option key={p.model_id} value={JSON.stringify(p)}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
|
||||
|
|
@ -218,6 +295,35 @@ export default function AdminPage() {
|
|||
)}
|
||||
|
||||
{tab === 'users' && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Add User</h2>
|
||||
<form onSubmit={async e => {
|
||||
e.preventDefault(); setError(''); setSuccess('')
|
||||
try {
|
||||
await api.post('/admin/users', newUser)
|
||||
setSuccess(`User ${newUser.email} created`)
|
||||
setNewUser({ name: '', email: '', password: '' })
|
||||
loadData()
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Failed to create user') }
|
||||
}}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value={newUser.name} onChange={e => setNewUser(u => ({ ...u, name: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={newUser.email} onChange={e => setNewUser(u => ({ ...u, email: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" value={newUser.password} onChange={e => setNewUser(u => ({ ...u, password: e.target.value }))} required minLength={8} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Users</h2>
|
||||
{users.map(u => (
|
||||
|
|
@ -243,6 +349,7 @@ export default function AdminPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,45 +1,103 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import LineChart from '../components/LineChart'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function greeting(name) {
|
||||
const h = new Date().getHours()
|
||||
const time = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'
|
||||
const first = name?.split(' ')[0] || ''
|
||||
return `${time}${first ? `, ${first}` : ''}`
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [history, setHistory] = useState([])
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/documents/'),
|
||||
api.get('/attempts/stats/dashboard'),
|
||||
]).then(([docsRes, statsRes]) => {
|
||||
api.get('/attempts/history'),
|
||||
]).then(([docsRes, statsRes, histRes]) => {
|
||||
setDocuments(docsRes.data)
|
||||
setStats(statsRes.data)
|
||||
setHistory(histRes.data)
|
||||
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
const greetingText = greeting(user?.name)
|
||||
|
||||
const selectedQuiz = history.find(q => q.quiz_id === selectedQuizId)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_documents}</div>
|
||||
<div className="stat-label">Documents</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_quizzes}</div>
|
||||
<div className="stat-label">Quizzes</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_attempts}</div>
|
||||
<div className="stat-label">Attempts</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.average_score}%</div>
|
||||
<div className="stat-label">Avg Score</div>
|
||||
{[
|
||||
{ value: stats.total_documents, label: 'Documents' },
|
||||
{ value: stats.total_quizzes, label: 'Quizzes' },
|
||||
{ value: stats.total_attempts, label: 'Attempts' },
|
||||
{ value: `${stats.average_score}%`, label: 'Avg Score' },
|
||||
].map(s => (
|
||||
<div className="stat-card" key={s.label}>
|
||||
<div className="stat-value">{s.value}</div>
|
||||
<div className="stat-label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance graph with dropdown */}
|
||||
{history.length > 0 && (
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0 }}>Performance</h2>
|
||||
<select
|
||||
value={selectedQuizId || ''}
|
||||
onChange={e => setSelectedQuizId(Number(e.target.value))}
|
||||
style={{ padding: '6px 12px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', maxWidth: 280 }}
|
||||
>
|
||||
{history.map(q => (
|
||||
<option key={q.quiz_id} value={q.quiz_id}>{q.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedQuiz && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 12, fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<span>{selectedQuiz.attempts.length} attempt{selectedQuiz.attempts.length !== 1 ? 's' : ''}</span>
|
||||
{selectedQuiz.attempts.length > 0 && (
|
||||
<>
|
||||
<span>Latest: <strong style={{
|
||||
color: selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage >= 75 ? '#22c55e' : '#ef4444'
|
||||
}}>{selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage}%</strong></span>
|
||||
<span>Best: <strong>{Math.max(...selectedQuiz.attempts.map(a => a.percentage))}%</strong></span>
|
||||
</>
|
||||
)}
|
||||
<Link to={`/quizzes/${selectedQuiz.quiz_id}`} className="btn btn-primary btn-sm" style={{ marginLeft: 'auto' }}>Retake</Link>
|
||||
</div>
|
||||
<LineChart data={selectedQuiz.attempts} />
|
||||
{selectedQuiz.attempts.length > 0 && selectedQuiz.attempts[selectedQuiz.attempts.length - 1].percentage < 75 && (
|
||||
<p style={{ margin: '8px 0 0', fontSize: '0.8rem', color: '#d97706' }}>
|
||||
⚠️ Below 75% — a reminder will be sent to review this quiz
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -48,11 +106,8 @@ export default function DashboardPage() {
|
|||
<h2>Your Documents</h2>
|
||||
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No documents yet. Upload a PDF to get started!</p>
|
||||
</div>
|
||||
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
|
||||
) : (
|
||||
documents.map(doc => (
|
||||
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
|
|
@ -69,28 +124,6 @@ export default function DashboardPage() {
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.quiz_stats.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>Quiz Performance</h2>
|
||||
{stats.quiz_stats.map(qs => (
|
||||
<div className="section-item" key={qs.quiz_id}>
|
||||
<div>
|
||||
<strong>{qs.quiz_title}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{qs.attempts_count} attempts | Best: {qs.best_score}%
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: qs.latest_score >= 70 ? '#22c55e' : qs.latest_score >= 50 ? '#f59e0b' : '#ef4444'
|
||||
}}>
|
||||
{qs.latest_score}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export default function DocumentDetailPage() {
|
|||
const [quizTitle, setQuizTitle] = useState('')
|
||||
const [quizMode, setQuizMode] = useState('timed')
|
||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState('')
|
||||
const [selectedModelId, setSelectedModelId] = useState('')
|
||||
const [availableModels, setAvailableModels] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
|
@ -26,7 +28,16 @@ export default function DocumentDetailPage() {
|
|||
}).catch(() => navigate('/'))
|
||||
}
|
||||
|
||||
useEffect(() => { fetchDoc() }, [id])
|
||||
useEffect(() => {
|
||||
fetchDoc()
|
||||
api.get('/admin/models/available?task=extraction')
|
||||
.then(res => {
|
||||
setAvailableModels(res.data)
|
||||
const def = res.data.find(m => m.is_default)
|
||||
if (def) setSelectedModelId(def.model_id)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || doc.status !== 'processing') return
|
||||
|
|
@ -66,6 +77,7 @@ export default function DocumentDetailPage() {
|
|||
title,
|
||||
mode: quizMode,
|
||||
time_limit_minutes: quizMode === 'timed' && timeLimitMinutes ? parseInt(timeLimitMinutes) : null,
|
||||
model_id: selectedModelId || null,
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
} catch (err) {
|
||||
|
|
@ -185,6 +197,18 @@ export default function DocumentDetailPage() {
|
|||
placeholder="Leave blank for no limit" />
|
||||
</div>
|
||||
)}
|
||||
{availableModels.length > 1 && (
|
||||
<div className="form-group">
|
||||
<label>Extraction Model</label>
|
||||
<select value={selectedModelId} onChange={e => setSelectedModelId(e.target.value)}>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.model_id} value={m.model_id}>
|
||||
{m.name}{m.is_default ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
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'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="auth-link">
|
||||
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||
</div>
|
||||
<div className="auth-link">
|
||||
Don't have an account? <Link to="/register">Sign up</Link>
|
||||
</div>
|
||||
|
|
|
|||
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 { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
function TTSButton({ text }) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
function TTSButton({ text, voice, onActiveChange }) {
|
||||
const [state, setState] = useState('idle') // idle | loading | playing
|
||||
const audioRef = useRef(null)
|
||||
|
||||
const speak = async () => {
|
||||
if (playing) {
|
||||
// Stop audio when question changes (component unmounts)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
setPlaying(false)
|
||||
onActiveChange?.(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setStateAndNotify = (s) => {
|
||||
setState(s)
|
||||
onActiveChange?.(s !== 'idle')
|
||||
}
|
||||
|
||||
const speak = async () => {
|
||||
if (state === 'playing') {
|
||||
audioRef.current?.pause()
|
||||
setStateAndNotify('idle')
|
||||
return
|
||||
}
|
||||
if (state === 'loading') return
|
||||
try {
|
||||
setPlaying(true)
|
||||
const res = await api.post('/tts/speak', { text }, { responseType: 'blob' })
|
||||
setStateAndNotify('loading')
|
||||
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => { setPlaying(false); URL.revokeObjectURL(url) }
|
||||
audio.onerror = () => setPlaying(false)
|
||||
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
|
||||
audio.onerror = () => setStateAndNotify('idle')
|
||||
audio.play()
|
||||
} catch {
|
||||
setPlaying(false)
|
||||
}
|
||||
setStateAndNotify('playing')
|
||||
} catch { setStateAndNotify('idle') }
|
||||
}
|
||||
|
||||
const label = state === 'loading' ? '⏳ Loading...' : state === 'playing' ? '⏹ Stop' : '🔊 Listen'
|
||||
const bg = state === 'playing' ? '#ef4444' : state === 'loading' ? '#e2e8f0' : '#e0e7ff'
|
||||
const color = state === 'playing' ? 'white' : state === 'loading' ? '#64748b' : '#3730a3'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={speak}
|
||||
title={playing ? 'Stop' : 'Read aloud'}
|
||||
style={{
|
||||
background: playing ? '#ef4444' : '#e0e7ff',
|
||||
color: playing ? 'white' : '#3730a3',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{playing ? '⏹ Stop' : '🔊 Listen'}
|
||||
<button onClick={speak} title={label} disabled={state === 'loading'} style={{
|
||||
background: bg, color, border: 'none', borderRadius: '6px',
|
||||
padding: '4px 10px', cursor: state === 'loading' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.8rem', marginLeft: 8, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TimerDisplay({ seconds, total }) {
|
||||
const pct = total > 0 ? (seconds / total) * 100 : 100
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
const mins = Math.floor(seconds / 60), secs = seconds % 60
|
||||
const color = pct > 50 ? '#22c55e' : pct > 20 ? '#f59e0b' : '#ef4444'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1.1rem', color }}>
|
||||
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
|
||||
{String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
|
||||
</div>
|
||||
<div style={{ width: 100, background: '#e2e8f0', borderRadius: 999, height: 6 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 999, transition: 'width 1s' }} />
|
||||
|
|
@ -64,10 +71,75 @@ function TimerDisplay({ seconds, total }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||||
const skipped = quiz.skipped_questions ? JSON.parse(quiz.skipped_questions) : []
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '40px auto' }}>
|
||||
<div className="card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
|
||||
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
|
||||
{quiz.questions_count} questions
|
||||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
||||
</p>
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: 12, marginBottom: 20, textAlign: 'left' }}>
|
||||
<p style={{ margin: '0 0 6px', fontWeight: 600, fontSize: '0.85rem', color: '#92400e' }}>
|
||||
⚠️ {skipped.length} question{skipped.length > 1 ? 's' : ''} skipped (no correct answer found):
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{skipped.map((q, i) => (
|
||||
<li key={i} style={{ fontSize: '0.8rem', color: '#78350f', marginBottom: 2 }}>{q}…</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
|
||||
{[
|
||||
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
|
||||
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Timed, answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
|
||||
].map(({ mode, icon, label, desc, color, bg }) => (
|
||||
<div key={mode} onClick={() => onStart(mode, selectedVoice)}
|
||||
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||||
>
|
||||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: '0.8rem', color }}>{desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{voices.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: 16 }}>
|
||||
<label style={{ fontSize: '0.85rem', color: '#64748b', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
|
||||
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
|
||||
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
|
||||
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuizPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const [quiz, setQuiz] = useState(null)
|
||||
const [voices, setVoices] = useState([])
|
||||
const [selectedVoice, setSelectedVoice] = useState('')
|
||||
const [ttsActive, setTtsActive] = useState(false)
|
||||
const [quizMode, setQuizMode] = useState(null)
|
||||
const [answers, setAnswers] = useState({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -76,214 +148,232 @@ export default function QuizPage() {
|
|||
const [timeLeft, setTimeLeft] = useState(null)
|
||||
const [totalTime, setTotalTime] = useState(null)
|
||||
const timerRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}`)
|
||||
const [quizRes, voicesRes] = await Promise.all([
|
||||
api.get(`/quizzes/${id}`),
|
||||
api.get('/tts/voices').catch(() => ({ data: [] })),
|
||||
])
|
||||
setQuiz(quizRes.data)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||
setAttemptId(attemptRes.data.id)
|
||||
|
||||
if (quizRes.data.mode === 'timed' && quizRes.data.time_limit_minutes) {
|
||||
const secs = quizRes.data.time_limit_minutes * 60
|
||||
setTimeLeft(secs)
|
||||
setTotalTime(secs)
|
||||
}
|
||||
} catch {
|
||||
navigate('/')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setVoices(voicesRes.data)
|
||||
const def = voicesRes.data.find(v => v.is_default)
|
||||
if (def) setSelectedVoice(def.id)
|
||||
} catch { navigate('/') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
load()
|
||||
return () => clearInterval(timerRef.current)
|
||||
}, [id])
|
||||
|
||||
// Timer countdown
|
||||
useEffect(() => {
|
||||
const startQuiz = async (mode, voice) => {
|
||||
if (hasStarted.current) return
|
||||
hasStarted.current = true
|
||||
setSelectedVoice(voice)
|
||||
setQuizMode(mode)
|
||||
|
||||
// Fetch full question data with study mode if needed
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
|
||||
setQuiz(quizRes.data)
|
||||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||
setAttemptId(attemptRes.data.id)
|
||||
if (mode === 'exam' && quizRes.data.time_limit_minutes) {
|
||||
const secs = quizRes.data.time_limit_minutes * 60
|
||||
setTimeLeft(secs); setTotalTime(secs)
|
||||
}
|
||||
} catch { navigate('/') }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft === null) return
|
||||
if (timeLeft <= 0) {
|
||||
handleSubmit(true)
|
||||
return
|
||||
}
|
||||
if (timeLeft <= 0) { handleSubmit(true); return }
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(t => {
|
||||
if (t <= 1) { clearInterval(timerRef.current); return 0 }
|
||||
return t - 1
|
||||
})
|
||||
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
|
||||
}, 1000)
|
||||
return () => clearInterval(timerRef.current)
|
||||
}, [timeLeft === null])
|
||||
|
||||
const setAnswer = (questionId, value) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
|
||||
// Warn before navigating away mid-quiz
|
||||
const safeNavigate = (targetIdx) => {
|
||||
if (Object.keys(answers).length > 0 && attemptId) {
|
||||
const confirmed = window.confirm(
|
||||
'You have answered some questions.\n\n• OK — go back (progress saved so far)\n• Cancel — stay on current question'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
setCurrentIdx(targetIdx)
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async (autoSubmit = false) => {
|
||||
if (!attemptId || submitting) return
|
||||
if (!autoSubmit && Object.keys(answers).length === 0) {
|
||||
if (!window.confirm('You haven\'t answered any questions. Submit anyway?')) return
|
||||
}
|
||||
clearInterval(timerRef.current)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const submission = {
|
||||
answers: Object.entries(answers).map(([qid, answer]) => ({
|
||||
question_id: parseInt(qid),
|
||||
user_answer: answer,
|
||||
question_id: parseInt(qid), user_answer: answer,
|
||||
})),
|
||||
}
|
||||
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
|
||||
navigate(`/results/${attemptId}`, { state: { result: res.data } })
|
||||
} catch (err) {
|
||||
if (!autoSubmit) alert(err.response?.data?.detail || 'Submission failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
} finally { setSubmitting(false) }
|
||||
}, [attemptId, answers, submitting])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||
if (!quiz) return null
|
||||
if (!quizMode) return (
|
||||
<div>
|
||||
{isModerator && (
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
</div>
|
||||
)}
|
||||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const isStudy = quizMode === 'study'
|
||||
const questions = quiz.questions || []
|
||||
const current = questions[currentIdx]
|
||||
const isLearning = quiz.mode === 'learning'
|
||||
const answeredCount = Object.keys(answers).length
|
||||
const totalCount = questions.length
|
||||
const isLast = currentIdx === totalCount - 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="quiz-bottom">
|
||||
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<span style={{
|
||||
background: isLearning ? '#d1fae5' : '#e0e7ff',
|
||||
color: isLearning ? '#065f46' : '#3730a3',
|
||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
|
||||
}}>
|
||||
{isLearning ? 'Learning Mode' : 'Timed Mode'}
|
||||
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
|
||||
</span>
|
||||
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
||||
</div>
|
||||
</div>
|
||||
{timeLeft !== null && (
|
||||
<TimerDisplay seconds={timeLeft} total={totalTime} />
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{voices.length > 1 && (
|
||||
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
|
||||
disabled={ttsActive}
|
||||
title={ttsActive ? 'Stop the current audio before changing voice' : ''}
|
||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.8rem', opacity: ttsActive ? 0.5 : 1 }}>
|
||||
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="progress-bar" style={{ marginBottom: 20 }}>
|
||||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Current question */}
|
||||
{current && (
|
||||
<div className="question-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<h3 style={{ flex: 1 }}>
|
||||
Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}
|
||||
</h3>
|
||||
<TTSButton text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} />
|
||||
<h3 style={{ flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
<TTSButton
|
||||
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||
voice={selectedVoice}
|
||||
onActiveChange={setTtsActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
|
||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||
</span>
|
||||
|
||||
{/* Question image */}
|
||||
{current.image_path && (
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<img
|
||||
src={`/uploads/${current.image_path}`}
|
||||
alt="Question illustration"
|
||||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
onError={e => e.target.style.display = 'none'}
|
||||
/>
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer options */}
|
||||
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
|
||||
<div className="options" style={{ marginTop: 12 }}>
|
||||
{current.options.map((opt, i) => {
|
||||
const isSelected = answers[current.id] === opt
|
||||
const isCorrect = isLearning && opt === current.correct_answer
|
||||
const isWrong = isLearning && isSelected && opt !== current.correct_answer
|
||||
const hasAnswered = isStudy && !!answers[current.id]
|
||||
const isCorrectOpt = opt === current.correct_answer
|
||||
const showCorrect = hasAnswered && isCorrectOpt
|
||||
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${isCorrect && isSelected ? 'correct' : ''} ${isWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => setAnswer(current.id, opt)}
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}
|
||||
>
|
||||
<input type="radio" name={`q-${current.id}`} checked={isSelected} onChange={() => setAnswer(current.id, opt)} />
|
||||
<span>{opt}</span>
|
||||
{isLearning && isCorrect && <span style={{ marginLeft: 'auto', color: '#22c55e', fontWeight: 600 }}>✓ Correct</span>}
|
||||
<span className="option-letter">{letter}</span>
|
||||
<span style={{ flex: 1 }}>{opt}</span>
|
||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your answer..."
|
||||
<input type="text" placeholder="Type your answer..."
|
||||
value={answers[current.id] || ''}
|
||||
onChange={e => setAnswer(current.id, e.target.value)}
|
||||
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }}
|
||||
/>
|
||||
style={{ marginTop: 12, width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8 }} />
|
||||
)}
|
||||
|
||||
{/* Learning mode: show explanation after answering */}
|
||||
{isLearning && answers[current.id] && current.explanation && (
|
||||
<div className="explanation" style={{ marginTop: 16 }}>
|
||||
<strong>Explanation:</strong> {current.explanation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Learning mode: show correct answer for fill blank */}
|
||||
{isLearning && current.question_type === 'fill_blank' && answers[current.id] && (
|
||||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||||
<strong>Answer:</strong> {current.correct_answer}
|
||||
</div>
|
||||
{/* Study mode: show explanation immediately after answering */}
|
||||
{isStudy && answers[current.id] && (
|
||||
<>
|
||||
{current.explanation && (
|
||||
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>
|
||||
<strong>Explanation:</strong> {current.explanation}
|
||||
</div>
|
||||
)}
|
||||
{current.question_type === 'fill_blank' && (
|
||||
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
|
||||
<strong>Correct Answer:</strong> {current.correct_answer}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
|
||||
<div className="quiz-nav">
|
||||
<button className="btn btn-secondary"
|
||||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||
disabled={currentIdx === 0}
|
||||
>← Previous</button>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div className="quiz-nav-numbers">
|
||||
{questions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setCurrentIdx(i)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
|
||||
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
|
||||
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
|
||||
}}
|
||||
>{i + 1}</button>
|
||||
<button key={q.id} onClick={() => safeNavigate(i)} style={{
|
||||
width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
|
||||
background: i === currentIdx ? '#2563eb' : answers[q.id] ? '#d1fae5' : '#e2e8f0',
|
||||
color: i === currentIdx ? 'white' : answers[q.id] ? '#065f46' : '#475569',
|
||||
}}>{i + 1}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}
|
||||
>Next →</button>
|
||||
<button className="btn btn-primary" onClick={() => setCurrentIdx(i => Math.min(totalCount - 1, i + 1))}>Next →</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,34 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
function HighlightText({ text, query }) {
|
||||
if (!query || !text) return <span>{text}</span>
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx === -1) return <span>{text}</span>
|
||||
return (
|
||||
<span>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const [quizzes, setQuizzes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchMode, setSearchMode] = useState('all')
|
||||
const [searchResults, setSearchResults] = useState(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const debounceRef = useRef(null)
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/').then(res => setQuizzes(res.data))
|
||||
|
|
@ -12,32 +36,199 @@ export default function QuizzesPage() {
|
|||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
if (searchQuery.trim().length < 2) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
|
||||
setSearchResults(res.data)
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 350)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, searchMode])
|
||||
|
||||
const deleteQuiz = async (e, quizId) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Delete this quiz and all its attempts?')) return
|
||||
try {
|
||||
await api.delete(`/quizzes/${quizId}`)
|
||||
setQuizzes(prev => prev.filter(q => q.id !== quizId))
|
||||
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete quiz')
|
||||
}
|
||||
}
|
||||
|
||||
const isSearching = searchQuery.trim().length >= 2
|
||||
const displayList = isSearching ? searchResults : null
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h2>Your Quizzes</h2>
|
||||
{quizzes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
|
||||
{/* Search bar */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '1rem' }}>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search quizzes or questions..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '9px 13px 9px 36px',
|
||||
border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
|
||||
<button key={val} onClick={() => setSearchMode(val)}
|
||||
className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{searching ? 'Searching...' : searchResults
|
||||
? `${searchResults.length} result${searchResults.length !== 1 ? 's' : ''} for "${searchQuery}"`
|
||||
: ''}
|
||||
</div>
|
||||
) : (
|
||||
quizzes.map(quiz => (
|
||||
<div className="section-item" key={quiz.id}>
|
||||
<div>
|
||||
<strong>{quiz.title}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{quiz.questions_count} questions | {new Date(quiz.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/quizzes/${quiz.id}`} className="btn btn-primary btn-sm">Take Quiz</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{isSearching && searchResults !== null && (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">No matches found for "{searchQuery}"</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{searchResults.map(result => (
|
||||
<div key={result.quiz_id} className="card" style={{ marginBottom: 12, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: result.matching_questions.length > 0 ? 12 : 0 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>
|
||||
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 8 }}>
|
||||
<span>{result.questions_count} questions</span>
|
||||
{result.match_type === 'both' && <span style={{ color: '#7c3aed' }}>• matched title + questions</span>}
|
||||
{result.match_type === 'questions' && <span style={{ color: '#2563eb' }}>• {result.matching_questions.length} matching question{result.matching_questions.length !== 1 ? 's' : ''}</span>}
|
||||
{result.match_type === 'title' && <span style={{ color: '#059669' }}>• title match</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isModerator && (
|
||||
<button onClick={e => deleteQuiz(e, result.quiz_id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
{result.matching_questions.slice(0, 3).map((q, i) => (
|
||||
<div key={q.id} style={{
|
||||
padding: '8px 12px', background: 'var(--bg)', borderRadius: 6,
|
||||
marginBottom: i < Math.min(result.matching_questions.length, 3) - 1 ? 6 : 0,
|
||||
fontSize: '0.85rem', color: 'var(--text-muted)',
|
||||
borderLeft: '3px solid var(--primary)',
|
||||
}}>
|
||||
<div style={{ marginBottom: q.options ? 4 : 0 }}>
|
||||
<HighlightText text={q.question_text.slice(0, 160) + (q.question_text.length > 160 ? '…' : '')} query={searchQuery} />
|
||||
</div>
|
||||
{q.options && q.options.some(o => o.toLowerCase().includes(searchQuery.toLowerCase())) && (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-subtle)', display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{q.options.filter(o => o.toLowerCase().includes(searchQuery.toLowerCase())).slice(0, 3).map((o, j) => (
|
||||
<span key={j} style={{ background: '#fef08a', padding: '1px 6px', borderRadius: 4, color: '#78350f', fontSize: '0.78rem' }}>
|
||||
<HighlightText text={o.slice(0, 80)} query={searchQuery} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{result.matching_questions.length > 3 && (
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 6, paddingLeft: 4 }}>
|
||||
+{result.matching_questions.length - 3} more matching question{result.matching_questions.length - 3 !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Normal quiz grid (hidden during active search) */}
|
||||
{!isSearching && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>All Quizzes</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: 4 }}>
|
||||
{quizzes.length} quiz{quizzes.length !== 1 ? 'zes' : ''} available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quizzes.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16, marginTop: 8 }}>
|
||||
{quizzes.map(quiz => (
|
||||
<div key={quiz.id} onClick={() => navigate(`/quizzes/${quiz.id}`)}
|
||||
style={{
|
||||
background: 'var(--card-bg)', border: 'var(--card-border)',
|
||||
borderRadius: 'var(--card-radius)', padding: '20px 18px',
|
||||
cursor: 'pointer', transition: 'box-shadow 0.15s, transform 0.15s',
|
||||
display: 'flex', flexDirection: 'column', gap: 10, position: 'relative',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}
|
||||
>
|
||||
{isModerator && (
|
||||
<button onClick={e => deleteQuiz(e, quiz.id)} title="Delete quiz"
|
||||
style={{ position: 'absolute', top: 10, right: 10, background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', lineHeight: 1, padding: 4, borderRadius: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
)}
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
|
||||
{quiz.mode === 'learning' ? '📖' : '📝'}
|
||||
</div>
|
||||
<div style={{ paddingRight: isModerator ? 20 : 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', lineHeight: 1.3 }}>{quiz.title}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>{quiz.questions_count} question{quiz.questions_count !== 1 ? 's' : ''}</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: 20, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', color: quiz.mode === 'learning' ? '#065f46' : '#3730a3' }}>
|
||||
{quiz.mode === 'learning' ? 'Learning' : 'Timed'}
|
||||
</span>
|
||||
{quiz.time_limit_minutes && <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{quiz.time_limit_minutes} min</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState('')
|
||||
|
|
@ -8,16 +9,22 @@ export default function RegisterPage() {
|
|||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [done, setDone] = useState(false)
|
||||
const { loginWithToken } = useAuth()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await register(email, password, name)
|
||||
navigate('/')
|
||||
const res = await api.post('/auth/register', { email, password, name })
|
||||
if (res.data.requires_verification) {
|
||||
setDone(true)
|
||||
} else {
|
||||
// First user (auto-verified admin) — log in immediately
|
||||
await loginWithToken(res.data.access_token)
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Registration failed')
|
||||
} finally {
|
||||
|
|
@ -25,6 +32,27 @@ export default function RegisterPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card" style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: 16 }}>📧</div>
|
||||
<h1 style={{ marginBottom: 12 }}>Check your email</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: 8 }}>
|
||||
We sent a verification link to <strong>{email}</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
||||
Click the link in the email to activate your account, then you can log in.
|
||||
</p>
|
||||
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
|
||||
<div className="auth-link" style={{ marginTop: 16 }}>
|
||||
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
|
|
@ -41,7 +69,7 @@ export default function RegisterPage() {
|
|||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6} />
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Sign Up'}
|
||||
|
|
|
|||
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 (!result) return null
|
||||
|
||||
const scoreClass = result.percentage >= 70 ? 'good' : result.percentage >= 50 ? 'ok' : 'poor'
|
||||
const pct = result.percentage
|
||||
const scoreClass = pct >= 75 ? 'good' : pct >= 50 ? 'ok' : 'poor'
|
||||
const correct = result.answers?.filter(a => a.is_correct).length ?? result.score
|
||||
const total = result.total_questions
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div style={{ maxWidth: 780, margin: '0 auto' }}>
|
||||
{/* Score card */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="score-display">
|
||||
<div className={`score-value ${scoreClass}`}>{result.percentage}%</div>
|
||||
<div style={{ fontSize: '1.1rem', color: '#64748b', marginTop: 8 }}>
|
||||
{result.score} out of {result.total_questions} correct
|
||||
<div className={`score-value ${scoreClass}`}>{pct}%</div>
|
||||
<div style={{ fontSize: '1.05rem', color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
{correct} of {total} correct
|
||||
</div>
|
||||
<div style={{ marginTop: 16, color: '#475569' }}>
|
||||
{result.percentage >= 90 ? 'Excellent! You\'ve mastered this material.' :
|
||||
result.percentage >= 70 ? 'Good job! A few more reviews and you\'ll nail it.' :
|
||||
result.percentage >= 50 ? 'Getting there! Keep practicing.' :
|
||||
'Don\'t worry — review the explanations below and try again!'}
|
||||
<div style={{ marginTop: 10, fontSize: '0.95rem', color: 'var(--text-muted)' }}>
|
||||
{pct >= 90 ? "Excellent — you've mastered this material." :
|
||||
pct >= 75 ? 'Good work! Review any missed questions below.' :
|
||||
pct >= 50 ? 'Getting there — study the explanations and retake.' :
|
||||
'Review all explanations carefully before retaking.'}
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||
|
||||
{/* Score bar */}
|
||||
<div style={{ maxWidth: 320, margin: '20px auto 24px' }}>
|
||||
<div style={{ height: 8, background: 'var(--border)', borderRadius: 999, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 999, transition: 'width 0.6s ease',
|
||||
width: `${pct}%`,
|
||||
background: pct >= 75 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4, fontSize: '0.75rem', color: 'var(--text-subtle)' }}>
|
||||
<span>0%</span><span style={{ color: '#d97706' }}>75%</span><span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
|
||||
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
|
||||
<Link to="/" className="btn btn-secondary">Dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginBottom: 16 }}>Question Review</h2>
|
||||
{result.answers?.map((ans, idx) => (
|
||||
<div className="question-card" key={idx} style={{
|
||||
borderLeftColor: ans.is_correct ? '#22c55e' : '#ef4444'
|
||||
}}>
|
||||
<h3>Q{idx + 1}. {ans.question_text}</h3>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, color: ans.is_correct ? '#22c55e' : '#ef4444' }}>
|
||||
{ans.is_correct ? 'Correct' : 'Incorrect'}
|
||||
{/* Summary row */}
|
||||
{result.answers && result.answers.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Correct', count: result.answers.filter(a => a.is_correct).length, color: '#22c55e', bg: '#dcfce7' },
|
||||
{ label: 'Incorrect', count: result.answers.filter(a => !a.is_correct && a.user_answer).length, color: '#ef4444', bg: '#fee2e2' },
|
||||
{ label: 'Skipped', count: result.answers.filter(a => !a.user_answer).length, color: 'var(--text-subtle)', bg: 'var(--border)' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ flex: 1, minWidth: 100, background: s.bg, borderRadius: 8, padding: '12px 16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: s.color }}>{s.count}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: s.color, fontWeight: 600 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: '1.1rem', color: 'var(--text)' }}>Question Review</h2>
|
||||
|
||||
{(!result.answers || result.answers.length === 0) ? (
|
||||
<div className="card"><div className="empty-state">No answers recorded.</div></div>
|
||||
) : result.answers.map((ans, idx) => {
|
||||
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
||||
return (
|
||||
<div className={`review-card ${cardClass}`} key={idx}>
|
||||
{/* Question header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-subtle)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Question {idx + 1}
|
||||
</span>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '1rem', fontWeight: 600, color: 'var(--text)', lineHeight: 1.55 }}>
|
||||
{ans.question_text}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0, padding: '4px 12px', borderRadius: 20, fontSize: '0.78rem', fontWeight: 700,
|
||||
background: ans.is_correct ? '#dcfce7' : ans.user_answer ? '#fee2e2' : 'var(--border)',
|
||||
color: ans.is_correct ? '#15803d' : ans.user_answer ? '#dc2626' : 'var(--text-muted)',
|
||||
}}>
|
||||
{ans.is_correct ? '✓ Correct' : ans.user_answer ? '✗ Incorrect' : '— Skipped'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<div><strong>Your answer:</strong> {ans.user_answer || '(no answer)'}</div>
|
||||
{!ans.is_correct && (
|
||||
<div style={{ color: '#22c55e' }}><strong>Correct answer:</strong> {ans.correct_answer}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options with letter badges */}
|
||||
{ans.options && ans.options.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{ans.options.map((opt, i) => {
|
||||
const isCorrect = opt === ans.correct_answer
|
||||
const isUser = opt === ans.user_answer
|
||||
const isWrong = isUser && !isCorrect
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
let bg = 'var(--option-bg)', border = '1px solid var(--border)', color = 'var(--text)'
|
||||
if (isCorrect) { bg = 'var(--correct-bg)'; border = `1.5px solid var(--correct-bd)`; color = 'var(--correct-fg)' }
|
||||
if (isWrong) { bg = 'var(--wrong-bg)'; border = `1.5px solid var(--wrong-bd)`; color = 'var(--wrong-fg)' }
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', borderRadius: 8, background: bg, border, color }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
||||
fontSize: '0.73rem', fontWeight: 700,
|
||||
background: isCorrect ? 'var(--correct-fg)' : isWrong ? 'var(--wrong-fg)' : 'var(--border)',
|
||||
color: isCorrect || isWrong ? 'white' : 'var(--text-muted)',
|
||||
}}>{letter}</span>
|
||||
<span style={{ flex: 1, fontSize: '0.9rem' }}>{opt}</span>
|
||||
{isCorrect && !isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Correct answer</span>}
|
||||
{isCorrect && isUser && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✓ Your answer</span>}
|
||||
{isWrong && <span style={{ fontSize: '0.78rem', fontWeight: 700 }}>✗ Your answer</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fill-blank */}
|
||||
{(!ans.options || ans.options.length === 0) && (
|
||||
<div style={{ marginBottom: 14, fontSize: '0.9rem' }}>
|
||||
<div style={{ padding: '8px 14px', background: 'var(--wrong-bg)', borderRadius: 6, marginBottom: 6 }}>
|
||||
<strong>Your answer:</strong> {ans.user_answer || <em style={{ color: 'var(--text-subtle)' }}>not answered</em>}
|
||||
</div>
|
||||
{!ans.is_correct && (
|
||||
<div style={{ padding: '8px 14px', background: 'var(--correct-bg)', borderRadius: 6 }}>
|
||||
<strong>Correct answer:</strong> {ans.correct_answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation */}
|
||||
{ans.explanation && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation</strong>
|
||||
<div style={{ marginTop: 8 }}>{ans.explanation}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ans.explanation && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong> {ans.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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