Add switchable embedding model, Polly toggle, job cancellation, and UI fixes

Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)

Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled

Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions

Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count

README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-03 20:44:11 +02:00
parent c94640c890
commit 12d99d3609
30 changed files with 961 additions and 181 deletions

View file

@ -20,7 +20,7 @@ AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automa
| Frontend | React + React Router, plain CSS, Nginx | | Frontend | React + React Router, plain CSS, Nginx |
| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector | | Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector |
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock) | | AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock) |
| Embeddings | AWS Bedrock Titan Embed V2 via LiteLLM proxy | | Embeddings | Configurable via Admin UI or `LITELLM_EMBEDDING_MODEL` env (default: `ge-gemini-embedding-001`) |
| Document vectors | ChromaDB | | Document vectors | ChromaDB |
| TTS | OpenAI, AWS Polly, ElevenLabs | | TTS | OpenAI, AWS Polly, ElevenLabs |
| Queue | Celery + Redis | | Queue | Celery + Redis |
@ -56,7 +56,7 @@ REDIS_URL=redis://redis:6379/0
LITELLM_MODEL=openai/claude-haiku-4.5 LITELLM_MODEL=openai/claude-haiku-4.5
LITELLM_API_KEY=<your-litellm-or-openai-key> LITELLM_API_KEY=<your-litellm-or-openai-key>
LITELLM_API_BASE=https://your-litellm-proxy.com # or leave empty for direct OpenAI LITELLM_API_BASE=https://your-litellm-proxy.com # or leave empty for direct OpenAI
LITELLM_EMBEDDING_MODEL=openai/titan-embed-v2 LITELLM_EMBEDDING_MODEL=ge-gemini-embedding-001 # model name as proxy knows it, no prefix needed
# OpenAI (for TTS — uses api.openai.com directly, not proxy) # OpenAI (for TTS — uses api.openai.com directly, not proxy)
OPENAI_API_KEY=<openai-api-key> OPENAI_API_KEY=<openai-api-key>
@ -88,6 +88,28 @@ MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data CHROMA_PERSIST_DIR=/app/chroma_data
``` ```
## Rebuild & Restart
```bash
# Rebuild and restart everything
docker compose build && docker compose up -d
# Rebuild and restart a single service (backend, frontend, or celery)
docker compose build backend && docker compose up -d backend
docker compose build frontend && docker compose up -d frontend
docker compose build celery && docker compose up -d celery
# Restart without rebuilding (picks up .env changes, not code changes)
docker compose restart backend
docker compose restart frontend
# View logs
docker compose logs backend --tail=50
docker compose logs celery --tail=50
```
> **Note:** Frontend and backend are built into Docker images — code changes require a `build` before they take effect. Only `.env` changes and volume-mounted data (uploads, chroma, postgres) are picked up by a plain `restart`.
## CLI Management ## CLI Management
All commands run inside the backend container: All commands run inside the backend container:

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings from app.config import settings
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
@ -122,7 +122,7 @@ def setup_pgvector():
"""Enable pgvector, add new columns/tables, run schema migrations.""" """Enable pgvector, add new columns/tables, run schema migrations."""
from sqlalchemy import text from sqlalchemy import text
# Import new models so create_all picks them up # Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category # noqa from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)")) conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
@ -193,6 +193,17 @@ def setup_pgvector():
""")) """))
# Make quiz_id on questions nullable (it becomes informational "source_quiz_id") # Make quiz_id on questions nullable (it becomes informational "source_quiz_id")
conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL")) conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL"))
# Favorites table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, question_id)
)
"""))
conn.commit() conn.commit()
conn.commit() conn.commit()
@ -260,8 +271,13 @@ app.add_middleware(
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["X-New-Token"], # Allow frontend to read this header
) )
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware)
# Serve uploaded images as static files # Serve uploaded images as static files
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
@ -275,6 +291,7 @@ app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]
app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -6,6 +6,7 @@ from app.models.question import Question
from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite
__all__ = [ __all__ = [
"User", "User",
@ -17,4 +18,5 @@ __all__ = [
"AttemptAnswer", "AttemptAnswer",
"ReminderSchedule", "ReminderSchedule",
"AIModelConfig", "AIModelConfig",
"Favorite",
] ]

View file

@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import Column, Integer, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class Favorite(Base):
__tablename__ = "favorites"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="favorites")
question = relationship("Question")

View file

@ -20,4 +20,4 @@ class ReminderSchedule(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reminders") user = relationship("User", back_populates="reminders")
quiz = relationship("Quiz") quiz = relationship("Quiz", back_populates="reminders")

View file

@ -20,6 +20,7 @@ class User(Base):
quizzes = relationship("Quiz", back_populates="user") quizzes = relationship("Quiz", back_populates="user")
attempts = relationship("QuizAttempt", back_populates="user") attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user") reminders = relationship("ReminderSchedule", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
@property @property
def is_admin(self): def is_admin(self):

View file

@ -56,11 +56,12 @@ def create_user(
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
from datetime import datetime from datetime import datetime
if db.query(User).filter(User.email == user_data.email).first(): email_normalized = user_data.email.lower().strip()
if db.query(User).filter(User.email == email_normalized).first():
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
user = User( user = User(
email=user_data.email, email=email_normalized,
hashed_password=get_password_hash(user_data.password), hashed_password=get_password_hash(user_data.password),
name=user_data.name, name=user_data.name,
role="user", role="user",
@ -98,19 +99,15 @@ def list_available_models(
return result return result
class LiteLLMModelQuery(BaseModel): @router.get("/litellm/models")
api_key: str | None = None
api_base: str | None = None
@router.post("/litellm/models")
def search_litellm_models( def search_litellm_models(
body: LiteLLMModelQuery = LiteLLMModelQuery(), api_key: str | None = None,
api_base: str | None = None,
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
"""Query available models from LiteLLM proxy or OpenAI-compatible API.""" """Query available models from LiteLLM proxy or OpenAI-compatible API."""
base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/") base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = body.api_key or settings.LITELLM_API_KEY key = api_key or settings.LITELLM_API_KEY
if base: if base:
try: try:
@ -202,3 +199,66 @@ def delete_model(
raise HTTPException(status_code=404, detail="Model config not found") raise HTTPException(status_code=404, detail="Model config not found")
db.delete(model) db.delete(model)
db.commit() db.commit()
# --- System Settings ---
@router.get("/settings")
def get_settings(admin: User = Depends(require_admin)):
"""Get system settings."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
registration_enabled = r.get("settings:registration_enabled")
embedding_model = r.get("settings:embedding_model")
polly_enabled = r.get("settings:polly_enabled")
return {
"registration_enabled": registration_enabled != "false",
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": polly_enabled != "false",
}
except Exception:
return {
"registration_enabled": True,
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": True,
}
@router.put("/settings")
def update_settings(
settings_data: dict,
admin: User = Depends(require_admin),
):
"""Update system settings."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
if "registration_enabled" in settings_data:
value = "true" if settings_data["registration_enabled"] else "false"
r.set("settings:registration_enabled", value)
if "embedding_model" in settings_data:
r.set("settings:embedding_model", settings_data["embedding_model"])
if "polly_enabled" in settings_data:
value = "true" if settings_data["polly_enabled"] else "false"
r.set("settings:polly_enabled", value)
return {"success": True, "message": "Settings updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
@router.post("/embedding/test")
def test_embedding(admin: User = Depends(require_admin)):
"""Test the currently configured embedding model."""
from app.services.embedding_service import generate_embedding, _get_embedding_model
model = _get_embedding_model()
if not model:
raise HTTPException(status_code=400, detail="No embedding model configured")
result = generate_embedding("The quick brown fox jumps over the lazy dog")
if result is None:
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
return {"model": model, "dimensions": len(result), "status": "ok"}

View file

@ -192,7 +192,9 @@ class ProgressSave(BaseModel):
current_idx: int current_idx: int
mode: str mode: str
voice: str | None = None voice: str | None = None
time_left: int | None = None # remaining seconds for timed mode time_left: int | None = None # remaining seconds for timed mode (informational, not authoritative)
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
total_time: int | None = None # original time limit in seconds
@router.post("/progress") @router.post("/progress")
@ -200,19 +202,23 @@ def save_progress(
data: ProgressSave, data: ProgressSave,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Save in-progress quiz answers to Redis (survives logout/browser change).""" """Save in-progress quiz answers to Redis (survives logout/browser change).
Each attempt gets its own saved progress (key includes attempt_id)."""
try: try:
import redis as redis_lib, json as _json import redis as redis_lib, json as _json
from app.config import settings from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{data.quiz_id}" key = f"quiz_progress:{current_user.id}:{data.attempt_id}"
r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days
"quiz_id": data.quiz_id,
"attempt_id": data.attempt_id, "attempt_id": data.attempt_id,
"answers": data.answers, "answers": data.answers,
"current_idx": data.current_idx, "current_idx": data.current_idx,
"mode": data.mode, "mode": data.mode,
"voice": data.voice, "voice": data.voice,
"time_left": data.time_left, "time_left": data.time_left,
"started_at": data.started_at,
"total_time": data.total_time,
})) }))
except Exception: except Exception:
pass # Redis unavailable — degrade gracefully pass # Redis unavailable — degrade gracefully
@ -226,35 +232,74 @@ def get_progress(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Retrieve in-progress quiz answers from Redis. """Retrieve in-progress quiz answers from Redis.
Clears stale data if the saved attempt has already been submitted.""" Finds the latest incomplete attempt for this quiz, then checks Redis.
Clears stale data if the saved attempt has already been submitted.
Auto-submits timed quizzes if timer has expired."""
try: try:
import redis as redis_lib, json as _json import redis as redis_lib, json as _json
from app.config import settings from app.config import settings
from datetime import datetime, timezone
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{quiz_id}"
# Find latest incomplete attempt for this quiz
attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).first()
if not attempt:
return None
key = f"quiz_progress:{current_user.id}:{attempt.id}"
data = r.get(key) data = r.get(key)
if not data: if not data:
return None return None
saved = _json.loads(data) saved = _json.loads(data)
attempt_id = saved.get("attempt_id")
if attempt_id: # Check if timer expired for timed quiz (auto-submit)
attempt = db.query(QuizAttempt).filter( total_time = saved.get("total_time")
QuizAttempt.id == attempt_id, started_at_str = saved.get("started_at")
QuizAttempt.user_id == current_user.id, if total_time is not None and started_at_str:
).first() try:
# Stale: attempt was submitted or doesn't exist started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00'))
if not attempt or attempt.completed_at is not None: elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
r.delete(key) if elapsed >= total_time:
return None # Timer expired — auto-submit
questions = {q.id: q for q in get_quiz_questions(db, quiz_id)}
score = 0
submitted_answers = saved.get("answers", {})
for qid_str, user_ans in submitted_answers.items():
qid = int(qid_str)
question = questions.get(qid)
if question:
correct = (question.correct_answer or "").strip().lower()
if (user_ans or "").strip().lower() == correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=qid,
user_answer=user_ans,
is_correct=(user_ans or "").strip().lower() == correct,
))
attempt.score = score
attempt.completed_at = datetime.utcnow()
db.commit()
r.delete(key)
return None # no progress to resume — already submitted
except Exception:
pass
return saved return saved
except Exception: except Exception:
pass pass
return None return None
@router.delete("/progress/{quiz_id}", status_code=204) @router.delete("/progress/{attempt_id}", status_code=204)
def clear_progress( def clear_progress(
quiz_id: int, attempt_id: int,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Clear saved progress when quiz is submitted.""" """Clear saved progress when quiz is submitted."""
@ -262,7 +307,7 @@ def clear_progress(
import redis as redis_lib import redis as redis_lib
from app.config import settings from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{quiz_id}") r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
except Exception: except Exception:
pass pass

View file

@ -43,7 +43,8 @@ RESET_WINDOW_HOURS = 1
def _check_reset_rate_limit(db: Session, email: str): def _check_reset_rate_limit(db: Session, email: str):
user = db.query(User).filter(User.email == email).first() email_normalized = email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user: if not user:
return # silently ignore unknown emails return # silently ignore unknown emails
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS) window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
@ -60,16 +61,31 @@ def _check_reset_rate_limit(db: Session, email: str):
@router.post("/register") @router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0
if not is_first_user:
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
registration_enabled = r.get("settings:registration_enabled")
# Default to enabled if not set, disabled if explicitly set to "false"
if registration_enabled == "false":
raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
except HTTPException:
raise
except Exception:
pass # Redis unavailable — allow registration (fail open)
if len(user_data.password) < 8: if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
existing = db.query(User).filter(User.email == user_data.email).first() email_normalized = user_data.email.lower().strip()
existing = db.query(User).filter(User.email == email_normalized).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
is_first_user = db.query(User).count() == 0
user = User( user = User(
email=user_data.email, email=email_normalized,
hashed_password=get_password_hash(user_data.password), hashed_password=get_password_hash(user_data.password),
name=user_data.name, name=user_data.name,
role="admin" if is_first_user else "user", role="admin" if is_first_user else "user",
@ -105,7 +121,8 @@ def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Requ
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip) _check_login_rate_limit(client_ip)
user = db.query(User).filter(User.email == login_data.email).first() email_normalized = login_data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user or not verify_password(login_data.password, user.hashed_password): if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password") raise HTTPException(status_code=401, detail="Invalid email or password")
@ -138,7 +155,8 @@ def verify_email(token: str, db: Session = Depends(get_db)):
@router.post("/resend-verification") @router.post("/resend-verification")
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == data.email).first() email_normalized = data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user: if not user:
return {"message": "If that email exists, a verification link has been sent."} return {"message": "If that email exists, a verification link has been sent."}
@ -160,9 +178,10 @@ async def resend_verification(data: ForgotPasswordRequest, background_tasks: Bac
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_check_reset_rate_limit(db, data.email) email_normalized = data.email.lower().strip()
_check_reset_rate_limit(db, email_normalized)
user = db.query(User).filter(User.email == data.email).first() user = db.query(User).filter(User.email == email_normalized).first()
# Always return same message to avoid email enumeration # Always return same message to avoid email enumeration
if not user: if not user:
return {"message": "If that email is registered, a reset link has been sent."} return {"message": "If that email is registered, a reset link has been sent."}

View file

@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models.user import User
from app.models.favorite import Favorite
from app.models.question import Question
from app.schemas.favorite import FavoriteCreate, FavoriteResponse
from app.utils.auth import get_current_user
router = APIRouter()
@router.post("", response_model=FavoriteResponse, status_code=201)
def add_favorite(
data: FavoriteCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add a question to user's favorites."""
# Check if question exists
question = db.query(Question).filter(Question.id == data.question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
# Check if already favorited
existing = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.question_id == data.question_id
).first()
if existing:
return existing # Already favorited, return existing
# Create new favorite
favorite = Favorite(
user_id=current_user.id,
question_id=data.question_id
)
db.add(favorite)
db.commit()
db.refresh(favorite)
return favorite
@router.delete("/{question_id}", status_code=204)
def remove_favorite(
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a question from user's favorites."""
favorite = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.question_id == question_id
).first()
if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found")
db.delete(favorite)
db.commit()
return None
@router.get("", response_model=list[int])
def list_favorites(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get list of favorited question IDs for current user."""
favorites = db.query(Favorite.question_id).filter(
Favorite.user_id == current_user.id
).all()
return [f[0] for f in favorites]

View file

@ -9,6 +9,7 @@ from app.models.question import Question
from app.models.question_category import QuestionCategory from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz from app.models.quiz import Quiz
from app.models.user import User from app.models.user import User
from app.models.favorite import Favorite
from app.utils.auth import get_current_user, require_moderator from app.utils.auth import get_current_user, require_moderator
router = APIRouter() router = APIRouter()
@ -40,6 +41,7 @@ def get_bank_ids(
quiz_id: int | None = Query(None), quiz_id: int | None = Query(None),
category_id: int | None = Query(None), category_id: int | None = Query(None),
uncategorized: bool = Query(False), uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -51,6 +53,12 @@ def get_bank_ids(
query = query.filter(Question.question_category_id == category_id) query = query.filter(Question.question_category_id == category_id)
if uncategorized: if uncategorized:
query = query.filter(Question.question_category_id.is_(None)) query = query.filter(Question.question_category_id.is_(None))
if favorites_only:
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
fav_ids = [f[0] for f in favorite_ids]
if not fav_ids:
return []
query = query.filter(Question.id.in_(fav_ids))
if q and q.strip(): if q and q.strip():
phrase = q.strip() phrase = q.strip()
query = query.filter( query = query.filter(
@ -68,6 +76,7 @@ def get_question_bank(
quiz_id: int | None = Query(None), quiz_id: int | None = Query(None),
category_id: int | None = Query(None), category_id: int | None = Query(None),
uncategorized: bool = Query(False), uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid" search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200), limit: int = Query(50, le=200),
offset: int = Query(0), offset: int = Query(0),
@ -86,6 +95,13 @@ def get_question_bank(
if uncategorized: if uncategorized:
query = query.filter(Question.question_category_id.is_(None)) query = query.filter(Question.question_category_id.is_(None))
if favorites_only:
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
fav_ids = [f[0] for f in favorite_ids]
if not fav_ids:
return {"total": 0, "questions": []}
query = query.filter(Question.id.in_(fav_ids))
# ── Semantic search (pgvector) ───────────────────────────────── # ── Semantic search (pgvector) ─────────────────────────────────
semantic_ids_ordered: list[int] = [] semantic_ids_ordered: list[int] = []
if q and q.strip() and search_mode in ("semantic", "hybrid"): if q and q.strip() and search_mode in ("semantic", "hybrid"):

View file

@ -10,7 +10,7 @@ from app.models.question import Question as QuestionModel
from app.models.section import Section from app.models.section import Section
from app.models.attempt import QuizAttempt from app.models.attempt import QuizAttempt
from app.models.user import User from app.models.user import User
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service from app.services import quiz_service
from app.utils.auth import get_current_user, require_moderator from app.utils.auth import get_current_user, require_moderator
from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz
@ -125,6 +125,23 @@ def get_extraction_job(job_id: str, current_user: User = Depends(require_moderat
return result return result
@router.post("/job/{job_id}/cancel")
def cancel_extraction_job(job_id: str, current_user: User = Depends(get_current_user)):
"""Cancel a running extraction job."""
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
# Verify this job belongs to the user
user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 19)
if job_id not in user_jobs:
raise HTTPException(status_code=403, detail="Job not found")
status = r.get(f"extraction:status:{job_id}")
if status != "running":
raise HTTPException(status_code=400, detail=f"Job is not running (status: {status})")
r.set(f"extraction:status:{job_id}", "cancelled")
return {"status": "cancelled"}
@router.get("/search") @router.get("/search")
def search_quizzes( def search_quizzes(
q: str = Query(..., min_length=2, max_length=200), q: str = Query(..., min_length=2, max_length=200),
@ -274,6 +291,26 @@ def get_quiz(
return QuizDetail.model_validate(quiz) return QuizDetail.model_validate(quiz)
@router.patch("/{quiz_id}", response_model=QuizResponse)
def update_quiz(
quiz_id: int,
data: QuizUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Update quiz metadata (title, etc.) — moderator only."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if data.title:
quiz.title = data.title.strip()
db.commit()
db.refresh(quiz)
return quiz
@router.post("/{quiz_id}/shuffle", response_model=QuizDetail) @router.post("/{quiz_id}/shuffle", response_model=QuizDetail)
def shuffle_quiz( def shuffle_quiz(
quiz_id: int, quiz_id: int,

View file

@ -18,19 +18,30 @@ class TTSRequest(BaseModel):
voice: str | None = None # model_id override voice: str | None = None # model_id override
def _polly_enabled() -> bool:
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
val = r.get("settings:polly_enabled")
return val != "false"
except Exception:
return True
@router.get("/voices") @router.get("/voices")
def get_voices( def get_voices(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return available TTS voices from DB + env fallback.""" """Return available TTS voices from DB, excluding Polly if disabled."""
db_models = db.query(AIModelConfig).filter( query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.task == "tts",
AIModelConfig.is_active == True, AIModelConfig.is_active == True,
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() )
if not _polly_enabled():
voices = [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] query = query.filter(~AIModelConfig.model_id.like("polly/%"))
return voices db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
@router.post("/speak") @router.post("/speak")
@ -46,6 +57,8 @@ def text_to_speech(
text = request.text[:2000] text = request.text[:2000]
if request.voice: if request.voice:
if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first() config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None api_key = config.api_key if (config and config.api_key) else None

View file

@ -0,0 +1,16 @@
from pydantic import BaseModel
from datetime import datetime
class FavoriteCreate(BaseModel):
question_id: int
class FavoriteResponse(BaseModel):
id: int
user_id: int
question_id: int
created_at: datetime
class Config:
from_attributes = True

View file

@ -18,6 +18,10 @@ class QuizCreate(BaseModel):
# ai_decide — AI reads a sample and decides which approach to use # ai_decide — AI reads a sample and decides which approach to use
class QuizUpdate(BaseModel):
title: str
class QuestionResponse(BaseModel): class QuestionResponse(BaseModel):
id: int id: int
question_text: str question_text: str

View file

@ -1,8 +1,9 @@
"""Embedding generation for semantic search via pgvector. """Embedding generation for semantic search via pgvector.
Priority: Priority:
1. AWS Bedrock Titan Embed V2 (direct, best quality, 1024 dims) 1. LiteLLM proxy model from Redis settings (overrides env)
2. LiteLLM proxy with configured LITELLM_EMBEDDING_MODEL (fallback) 2. LiteLLM proxy model from LITELLM_EMBEDDING_MODEL env
3. AWS Bedrock Titan Embed V2 (direct fallback)
""" """
import logging import logging
@ -11,6 +12,19 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_embedding_model() -> str:
"""Return the active embedding model: Redis setting takes precedence over env."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
model = r.get("settings:embedding_model")
if model:
return model
except Exception:
pass
return settings.LITELLM_EMBEDDING_MODEL
def _text_for_question(question_text: str, options: list[str] | None) -> str: def _text_for_question(question_text: str, options: list[str] | None) -> str:
"""Build the text to embed for a question — stem + options, no explanation.""" """Build the text to embed for a question — stem + options, no explanation."""
parts = [question_text] parts = [question_text]
@ -30,20 +44,23 @@ def generate_embedding(text: str) -> list[float] | None:
if not clean: if not clean:
return None return None
# ── 1. LiteLLM proxy (best quality) ──────────────────────── # ── 1. LiteLLM proxy (direct httpx — avoids LiteLLM param validation) ──
if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY: embedding_model = _get_embedding_model()
api_base = (settings.LITELLM_API_BASE or "").rstrip("/")
if embedding_model and settings.LITELLM_API_KEY and api_base:
try: try:
import litellm import httpx, json as _json
resp = litellm.embedding( body: dict = {"model": embedding_model, "input": [clean], "dimensions": settings.EMBEDDING_DIMENSIONS}
model=settings.LITELLM_EMBEDDING_MODEL, resp = httpx.post(
input=[clean], f"{api_base}/v1/embeddings",
api_key=settings.LITELLM_API_KEY, headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
api_base=settings.LITELLM_API_BASE or None, content=_json.dumps(body),
timeout=30,
) )
emb = resp.data[0]["embedding"] resp.raise_for_status()
emb = resp.json()["data"][0]["embedding"]
if len(emb) == settings.EMBEDDING_DIMENSIONS: if len(emb) == settings.EMBEDDING_DIMENSIONS:
return emb return emb
# Dimension mismatch — don't store incompatible vector
logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}") logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}")
except Exception as e: except Exception as e:
logger.warning(f"LiteLLM embedding failed: {e}") logger.warning(f"LiteLLM embedding failed: {e}")

View file

@ -7,20 +7,22 @@ _client = None
class LiteLLMEmbeddingFunction(EmbeddingFunction): class LiteLLMEmbeddingFunction(EmbeddingFunction):
"""ChromaDB embedding function backed by LiteLLM.""" """ChromaDB embedding function backed by the LiteLLM proxy (direct httpx)."""
def __call__(self, input: list[str]) -> Embeddings: def __call__(self, input: list[str]) -> Embeddings:
import litellm import httpx, json as _json
kwargs = { from app.services.embedding_service import _get_embedding_model
"model": settings.LITELLM_EMBEDDING_MODEL, model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL
"input": input, api_base = (settings.LITELLM_API_BASE or "").rstrip("/")
} body = {"model": model, "input": input, "dimensions": settings.EMBEDDING_DIMENSIONS}
if settings.LITELLM_API_KEY: resp = httpx.post(
kwargs["api_key"] = settings.LITELLM_API_KEY f"{api_base}/v1/embeddings",
if settings.LITELLM_API_BASE: headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
kwargs["api_base"] = settings.LITELLM_API_BASE content=_json.dumps(body),
response = litellm.embedding(**kwargs) timeout=60,
return [item["embedding"] for item in response.data] )
resp.raise_for_status()
return [item["embedding"] for item in resp.json()["data"]]
def get_client() -> chromadb.PersistentClient: def get_client() -> chromadb.PersistentClient:
@ -32,8 +34,10 @@ def get_client() -> chromadb.PersistentClient:
def get_or_create_collection(document_id: int): def get_or_create_collection(document_id: int):
client = get_client() client = get_client()
# Only use custom embedding if model is configured and has a provider prefix from app.services.embedding_service import _get_embedding_model
use_ef = bool(settings.LITELLM_EMBEDDING_MODEL and "/" in settings.LITELLM_EMBEDDING_MODEL) active_model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL
# Only use custom embedding if model and API base are configured
use_ef = bool(active_model and settings.LITELLM_API_BASE)
ef = LiteLLMEmbeddingFunction() if use_ef else None ef = LiteLLMEmbeddingFunction() if use_ef else None
kwargs = {"name": f"doc_{document_id}"} kwargs = {"name": f"doc_{document_id}"}
if ef: if ef:

View file

@ -122,6 +122,9 @@ def extract_quiz(
if resolved_mode == "questions_only": if resolved_mode == "questions_only":
_push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.") _push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}") _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
chunk_content = vector_service.get_pages_text( chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p) document_id=section.document_id, start_page=start_p, end_page=end_p)
@ -160,6 +163,9 @@ def extract_quiz(
if extraction_mode == "standard": if extraction_mode == "standard":
# ── STANDARD: existing working extraction (unchanged) ────────────── # ── STANDARD: existing working extraction (unchanged) ──────────────
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
if n_chunks > 1: if n_chunks > 1:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}{model_name}") _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}{model_name}")
else: else:

View file

@ -1,10 +1,11 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db
@ -25,10 +26,18 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy() to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire}) to_encode.update({"exp": expire, "iat": datetime.utcnow().timestamp()})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str):
"""Decode JWT token and return payload, or None if invalid."""
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
return None
def get_current_user( def get_current_user(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -46,7 +55,7 @@ def get_current_user(
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email.lower().strip()).first()
if user is None: if user is None:
raise credentials_exception raise credentials_exception
@ -73,3 +82,38 @@ def require_moderator(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_moderator: if not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Moderator access required") raise HTTPException(status_code=403, detail="Moderator access required")
return current_user return current_user
class TokenRefreshMiddleware(BaseHTTPMiddleware):
"""Middleware to refresh JWT token if it's older than 12 hours or expires soon (sliding expiration)."""
async def dispatch(self, request: Request, call_next):
# Extract token from Authorization header
auth_header = request.headers.get("authorization", "")
new_token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:]
payload = decode_token(token)
if payload:
iat = payload.get("iat")
exp = payload.get("exp")
now = datetime.utcnow().timestamp()
if iat and exp:
age_hours = (now - iat) / 3600
time_until_expiry_hours = (exp - now) / 3600
# Refresh if: token > 12 hours old OR will expire in < 1 hour
if age_hours > 12 or time_until_expiry_hours < 1:
email = payload.get("sub")
if email:
new_token = create_access_token({"sub": email})
response = await call_next(request)
# Add new token to response headers if generated
if new_token:
response.headers["X-New-Token"] = new_token
return response

View file

@ -20,6 +20,7 @@ import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage' import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage' import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage'
function ProtectedRoute({ children, requireModerator = false }) { function ProtectedRoute({ children, requireModerator = false }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@ -66,6 +67,7 @@ function AppRoutes() {
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} /> <Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} /> <Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} /> <Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
<Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</div> </div>
<Footer /> <Footer />

View file

@ -13,8 +13,21 @@ api.interceptors.request.use((config) => {
}) })
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => {
// Sliding token expiration: if server sends new token, update localStorage
const newToken = response.headers['x-new-token']
if (newToken) {
localStorage.setItem('token', newToken)
}
return response
},
(error) => { (error) => {
// Check for token refresh even on error responses
const newToken = error.response?.headers?.['x-new-token']
if (newToken) {
localStorage.setItem('token', newToken)
}
const url = error.config?.url || '' const url = error.config?.url || ''
// Don't redirect to login from the login/register/verify endpoints themselves // Don't redirect to login from the login/register/verify endpoints themselves
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') ||

View file

@ -15,7 +15,7 @@ function JobsBadge() {
const res = await api.get('/quizzes/jobs') const res = await api.get('/quizzes/jobs')
const jobs = res.data || [] const jobs = res.data || []
setAllJobs(jobs) setAllJobs(jobs)
const running = jobs.filter(j => j.status !== 'completed' && j.status !== 'failed') const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
setActiveJobs(running) setActiveJobs(running)
// Poll faster when jobs running, slower when idle // Poll faster when jobs running, slower when idle
clearInterval(interval) clearInterval(interval)

View file

@ -11,6 +11,7 @@ export default function AdminPage() {
const [tab, setTab] = useState('models') const [tab, setTab] = useState('models')
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [models, setModels] = useState([]) const [models, setModels] = useState([])
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
@ -26,6 +27,14 @@ export default function AdminPage() {
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
const [searchFilter, setSearchFilter] = useState('') const [searchFilter, setSearchFilter] = useState('')
// Embedding model search state (in settings tab)
const [embedSearchResults, setEmbedSearchResults] = useState([])
const [embedSearchLoading, setEmbedSearchLoading] = useState(false)
const [embedSearchError, setEmbedSearchError] = useState('')
const [embedSearchFilter, setEmbedSearchFilter] = useState('')
const [embedTestResult, setEmbedTestResult] = useState(null)
const [embedTestLoading, setEmbedTestLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return } if (!user?.role || user.role !== 'admin') { navigate('/'); return }
loadData() loadData()
@ -34,12 +43,14 @@ export default function AdminPage() {
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const [usersRes, modelsRes] = await Promise.all([ const [usersRes, modelsRes, settingsRes] = await Promise.all([
api.get('/admin/users'), api.get('/admin/users'),
api.get('/admin/models'), api.get('/admin/models'),
api.get('/admin/settings'),
]) ])
setUsers(usersRes.data) setUsers(usersRes.data)
setModels(modelsRes.data) setModels(modelsRes.data)
setSettings(settingsRes.data)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to load data') setError(err.response?.data?.detail || 'Failed to load data')
} finally { } finally {
@ -116,6 +127,46 @@ export default function AdminPage() {
} }
} }
const searchEmbeddingModels = async () => {
setEmbedSearchError('')
setEmbedSearchResults([])
setEmbedSearchLoading(true)
try {
const res = await api.get('/admin/litellm/models')
const all = res.data.models || []
// Filter to likely embedding models
setEmbedSearchResults(all)
} catch (err) {
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
} finally {
setEmbedSearchLoading(false)
}
}
const testEmbedding = async () => {
setEmbedTestResult(null)
setEmbedTestLoading(true)
try {
const res = await api.post('/admin/embedding/test')
setEmbedTestResult({ ok: true, ...res.data })
} catch (err) {
setEmbedTestResult({ ok: false, error: err.response?.data?.detail || 'Test failed' })
} finally {
setEmbedTestLoading(false)
}
}
const saveEmbeddingModel = async (modelId) => {
try {
await api.put('/admin/settings', { embedding_model: modelId })
setSettings(s => ({ ...s, embedding_model: modelId }))
setEmbedSearchResults([])
setSuccess(`Embedding model set to: ${modelId}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save embedding model')
}
}
const addFromSearch = (modelId) => { const addFromSearch = (modelId) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId })) setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
setSearchResults([]) setSearchResults([])
@ -139,9 +190,9 @@ export default function AdminPage() {
<div className="card"> <div className="card">
<h2>Admin Dashboard</h2> <h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{['models', 'users'].map(t => ( {['models', 'users', 'settings'].map(t => (
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}> <button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
{t === 'models' ? 'AI Models' : 'Users'} {t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
</button> </button>
))} ))}
</div> </div>
@ -237,11 +288,11 @@ export default function AdminPage() {
) : ( ) : (
modelsByTask[task].map(m => ( modelsByTask[task].map(m => (
<div className="section-item" key={m.id}> <div className="section-item" key={m.id}>
<div> <div style={{ minWidth: 0 }}>
<strong>{m.name}</strong> <strong>{m.name}</strong>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace' }}>{m.model_id}</div> <div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
</div> </div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{m.is_default && <span className="badge badge-ready">Default</span>} {m.is_default && <span className="badge badge-ready">Default</span>}
{!m.is_default && ( {!m.is_default && (
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button> <button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
@ -351,6 +402,144 @@ export default function AdminPage() {
</div> </div>
</> </>
)} )}
{tab === 'settings' && (
<div className="card">
<h2>More Settings</h2>
<div style={{ marginTop: 16 }}>
{/* Public Registration */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>Public Registration</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Allow new users to create accounts. Admins can always create users manually.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.registration_enabled}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, registration_enabled: enabled }))
try {
await api.put('/admin/settings', { registration_enabled: enabled })
setSuccess(`Registration ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, registration_enabled: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.registration_enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
{/* AWS Polly */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.polly_enabled}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, polly_enabled: enabled }))
try {
await api.put('/admin/settings', { polly_enabled: enabled })
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, polly_enabled: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
{/* Embedding Model */}
<div style={{ padding: '16px 0' }}>
<strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 12 }}>
Model used to generate semantic search vectors. Must output {1024} dimensions to match the database schema.
Current: <code style={{ background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 4 }}>{settings.embedding_model || '(not set)'}</code>
</span>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
type="text"
value={settings.embedding_model}
onChange={e => setSettings(s => ({ ...s, embedding_model: e.target.value }))}
placeholder="e.g. ge-gemini-embedding-001"
style={{ flex: 1, minWidth: 0 }}
/>
<button className="btn btn-primary btn-sm" onClick={() => saveEmbeddingModel(settings.embedding_model)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={searchEmbeddingModels} disabled={embedSearchLoading}>
{embedSearchLoading ? 'Searching...' : 'Search LiteLLM'}
</button>
<button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}>
{embedTestLoading ? 'Testing...' : 'Test'}
</button>
</div>
{embedTestResult && (
<div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}>
{embedTestResult.ok
? `OK — ${embedTestResult.model} · ${embedTestResult.dimensions} dims`
: embedTestResult.error}
</div>
)}
{embedSearchError && <div className="alert alert-error" style={{ marginBottom: 8 }}>{embedSearchError}</div>}
{embedSearchResults.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{embedSearchResults.length} embedding models found click to select</span>
<input
type="text"
placeholder="Filter..."
value={embedSearchFilter}
onChange={e => setEmbedSearchFilter(e.target.value)}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 180 }}
/>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
{embedSearchResults
.filter(m => !embedSearchFilter || m.toLowerCase().includes(embedSearchFilter.toLowerCase()))
.map(modelId => (
<div
key={modelId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid var(--border)',
fontSize: '0.85rem', fontFamily: 'monospace',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelId}</span>
<button className="btn btn-primary btn-sm" style={{ flexShrink: 0 }} onClick={() => saveEmbeddingModel(modelId)}>
Select
</button>
</div>
))
}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View file

@ -63,6 +63,17 @@ function JobDetail({ job }) {
{job.quiz_id && ( {job.quiz_id && (
<Link to={`/quizzes/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link> <Link to={`/quizzes/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
)} )}
{job.status === 'running' && (
<button className="btn btn-danger btn-sm" onClick={async () => {
try {
await api.post(`/quizzes/job/${job.job_id}/cancel`)
job.status = 'cancelled'
load()
} catch (e) {
alert(e.response?.data?.detail || 'Failed to cancel')
}
}}>Cancel</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setExpanded(v => !v)}> <button className="btn btn-secondary btn-sm" onClick={() => setExpanded(v => !v)}>
{expanded ? 'Hide' : 'Details'} {expanded ? 'Hide' : 'Details'}
</button> </button>

View file

@ -0,0 +1,14 @@
import { Link } from 'react-router-dom'
export default function NotFoundPage() {
return (
<div style={{ textAlign: 'center', padding: '60px 20px', maxWidth: 500, margin: '0 auto' }}>
<div style={{ fontSize: '4rem', marginBottom: 16 }}>404</div>
<h1 style={{ marginBottom: 12, fontSize: '1.5rem' }}>Page Not Found</h1>
<p style={{ color: 'var(--text-muted)', marginBottom: 24, lineHeight: 1.6 }}>
The page you're looking for doesn't exist or has been moved.
</p>
<Link to="/" className="btn btn-primary"> Back to Dashboard</Link>
</div>
)
}

View file

@ -227,6 +227,8 @@ export default function QuestionBankPage() {
const [searchMode, setSearchMode] = useState('hybrid') const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatId, setFilterCatId] = useState('') const [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false) const [showUncategorized, setShowUncategorized] = useState(false)
const [showFavorites, setShowFavorites] = useState(false)
const [favorites, setFavorites] = useState([])
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [studyQuestion, setStudyQuestion] = useState(null) const [studyQuestion, setStudyQuestion] = useState(null)
const [editQuestion, setEditQuestion] = useState(null) const [editQuestion, setEditQuestion] = useState(null)
@ -244,15 +246,17 @@ export default function QuestionBankPage() {
useEffect(() => { useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {}) api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {})
}, []) }, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode, size = pageSize) => { const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize) => {
setLoading(true) setLoading(true)
try { try {
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode } const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim() if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId) if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true if (uncatOnly) params.uncategorized = true
if (favOnly) params.favorites_only = true
const res = await api.get('/questions/bank', { params }) const res = await api.get('/questions/bank', { params })
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions]) setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
setTotal(res.data.total) setTotal(res.data.total)
@ -262,9 +266,9 @@ export default function QuestionBankPage() {
useEffect(() => { useEffect(() => {
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode, pageSize), 300) debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, showFavorites, searchMode, pageSize), 300)
return () => clearTimeout(debounceRef.current) return () => clearTimeout(debounceRef.current)
}, [searchQuery, filterCatId, showUncategorized, searchMode, pageSize]) }, [searchQuery, filterCatId, showUncategorized, showFavorites, searchMode, pageSize])
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
@ -275,6 +279,7 @@ export default function QuestionBankPage() {
if (searchQuery.trim()) params.q = searchQuery.trim() if (searchQuery.trim()) params.q = searchQuery.trim()
if (filterCatId) params.category_id = parseInt(filterCatId) if (filterCatId) params.category_id = parseInt(filterCatId)
if (showUncategorized) params.uncategorized = true if (showUncategorized) params.uncategorized = true
if (showFavorites) params.favorites_only = true
const res = await api.get('/questions/bank/ids', { params }) const res = await api.get('/questions/bank/ids', { params })
setSelectedIds(new Set(res.data)) setSelectedIds(new Set(res.data))
} catch { } catch {
@ -326,7 +331,7 @@ export default function QuestionBankPage() {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} }) await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId)) setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('') if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized) loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites)
} catch { } } catch { }
} }
@ -335,6 +340,21 @@ export default function QuestionBankPage() {
setMoveToCatId('') setMoveToCatId('')
} }
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
try {
if (isFavorited) {
await api.delete(`/favorites/${questionId}`)
setFavorites(prev => prev.filter(id => id !== questionId))
} else {
await api.post('/favorites', { question_id: questionId })
setFavorites(prev => [...prev, questionId])
}
} catch (err) {
alert(err.response?.data?.detail || 'Failed to update favorite')
}
}
return ( return (
<div> <div>
{/* Delete category dialog */} {/* Delete category dialog */}
@ -398,26 +418,26 @@ export default function QuestionBankPage() {
</div> </div>
{/* Category filter chips */} {/* Category filter chips */}
{categories.length > 0 && ( <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}> <span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span> <button className={`btn btn-sm ${!filterCatId && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
<button className={`btn btn-sm ${!filterCatId && !showUncategorized ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setFilterCatId(''); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All ({total})</button> <button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setShowFavorites(v => !v); setFilterCatId(''); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
onClick={() => { setShowUncategorized(v => !v); setFilterCatId('') }}>Uncategorized</button> <button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
{categories.map(cat => ( onClick={() => { setShowUncategorized(v => !v); setFilterCatId(''); setShowFavorites(false) }}>Uncategorized</button>
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}> {categories.map(cat => (
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`} <div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false) }}> <button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span> onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false); setShowFavorites(false) }}>
</button> {cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
{isModerator && <button onClick={() => deleteCategory(cat.id)} </button>
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }} {isModerator && <button onClick={() => deleteCategory(cat.id)}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
</div> onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
))} </div>
</div> ))}
)} </div>
{/* Search row */} {/* Search row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
@ -514,6 +534,20 @@ export default function QuestionBankPage() {
)} )}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
<button
onClick={() => toggleFavorite(q.id)}
title={favorites.includes(q.id) ? 'Remove from favorites' : 'Add to favorites'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.3rem',
padding: 4,
lineHeight: 1,
}}
>
{favorites.includes(q.id) ? '⭐' : '☆'}
</button>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button> <button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>} {isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
</div> </div>
@ -526,7 +560,7 @@ export default function QuestionBankPage() {
{questions.length < total && ( {questions.length < total && (
<div style={{ textAlign: 'center', marginTop: 12 }}> <div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized)} disabled={loading}> <button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized, showFavorites)} disabled={loading}>
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`} {loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
</button> </button>
</div> </div>

View file

@ -155,6 +155,9 @@ export default function QuizEditPage() {
const [questions, setQuestions] = useState([]) const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
const [savingTitle, setSavingTitle] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@ -162,6 +165,7 @@ export default function QuizEditPage() {
api.get(`/quizzes/${id}/questions`), api.get(`/quizzes/${id}/questions`),
]).then(([qRes, questRes]) => { ]).then(([qRes, questRes]) => {
setQuiz(qRes.data) setQuiz(qRes.data)
setTitleValue(qRes.data.title)
setQuestions(questRes.data) setQuestions(questRes.data)
}).catch(() => navigate('/quizzes')) }).catch(() => navigate('/quizzes'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@ -178,16 +182,65 @@ export default function QuizEditPage() {
setQuestions(prev => prev.filter(q => q.id !== questionId)) setQuestions(prev => prev.filter(q => q.id !== questionId))
} }
const saveTitle = async () => {
if (!titleValue.trim()) { setError('Title cannot be empty'); return }
setSavingTitle(true); setError('')
try {
const res = await api.patch(`/quizzes/${id}`, { title: titleValue.trim() })
setQuiz(res.data)
setTitleValue(res.data.title)
setEditingTitle(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update title')
} finally { setSavingTitle(false) }
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading</div>
if (!quiz) return null if (!quiz) return null
return ( return (
<div style={{ maxWidth: 780, margin: '0 auto' }}> <div style={{ maxWidth: 780, margin: '0 auto' }}>
<div className="card" style={{ marginBottom: 8 }}> <div className="card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div> <div style={{ flex: 1 }}>
<h2 style={{ marginBottom: 4 }}>Edit Quiz</h2> <h2 style={{ marginBottom: 4 }}>Edit Quiz</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{quiz.title} · {questions.length} questions</p> {!editingTitle ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', margin: 0 }}>
{quiz.title} · {questions.length} questions
</p>
<button
className="btn btn-secondary btn-sm"
onClick={() => setEditingTitle(true)}
style={{ fontSize: '0.75rem', padding: '2px 8px' }}
title="Edit quiz title"
>
Edit Title
</button>
</div>
) : (
<div style={{ marginTop: 8 }}>
<input
type="text"
value={titleValue}
onChange={e => setTitleValue(e.target.value)}
placeholder="Quiz title"
style={{ width: '100%', padding: '6px 10px', marginBottom: 8, fontSize: '0.9rem' }}
autoFocus
/>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary btn-sm" onClick={saveTitle} disabled={savingTitle}>
{savingTitle ? 'Saving...' : 'Save'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => { setEditingTitle(false); setTitleValue(quiz.title); setError('') }}
>
Cancel
</button>
</div>
</div>
)}
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Link to={`/quizzes/${id}`} className="btn btn-secondary btn-sm"> Back to Quiz</Link> <Link to={`/quizzes/${id}`} className="btn btn-secondary btn-sm"> Back to Quiz</Link>

View file

@ -71,16 +71,9 @@ function TimerDisplay({ seconds, total }) {
) )
} }
function ModeSelectScreen({ quiz, voices, onStart, onResume }) { function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [saved, setSaved] = useState(null)
useEffect(() => {
api.get('/attempts/progress', { params: { quiz_id: quiz.id } })
.then(res => { if (res.data) setSaved(res.data) })
.catch(() => {})
}, [quiz.id])
return ( return (
<div style={{ maxWidth: 520, margin: '40px auto' }}> <div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -91,19 +84,6 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
{quiz.questions_count} questions {quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''} {quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</p> </p>
{saved && saved.attempt_id && (
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--option-sel-bd)', borderRadius: 8, padding: '12px 14px', marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>You have an in-progress attempt</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Q{(saved.current_idx ?? 0) + 1} · {Object.keys(saved.answers || {}).length} answered
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => onResume(saved)}>Resume </button>
</div>
)}
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p> <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 }}> <div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[ {[
@ -165,6 +145,8 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null) const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('') const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([])
const timerRef = useRef(null) const timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
@ -177,25 +159,73 @@ export default function QuizPage() {
const [leaveTarget, setLeaveTarget] = useState(null) const [leaveTarget, setLeaveTarget] = useState(null)
const resumeQuiz = useCallback(async (saved) => {
const mode = saved.mode || saved.quizMode
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {}
// For study mode, load quiz with correct answers BEFORE showing
if (mode === 'study') {
try {
const quizRes = await api.get(`/quizzes/${id}?study=true`)
setQuiz(quizRes.data)
} catch { }
} else {
// For exam mode, ensure quiz data is loaded
try {
const quizRes = await api.get(`/quizzes/${id}`)
setQuiz(quizRes.data)
} catch { }
}
hasStarted.current = true
setQuizMode(mode)
setAnswers(savedAnswers)
setCurrentIdx(savedIdx)
setAttemptId(saved.attempt_id || saved.attemptId)
if (saved.voice) setSelectedVoice(saved.voice)
if (saved.started_at) setStartedAt(saved.started_at)
// Restore timer calculate remaining from started_at + total_time
if (saved.total_time && saved.started_at) {
const elapsed = Math.floor((new Date() - new Date(saved.started_at)) / 1000)
const remaining = Math.max(0, saved.total_time - elapsed)
setTimeLeft(remaining)
setTotalTime(saved.total_time)
}
}, [id])
// Warn before tab/window close when mid-quiz // Warn before tab/window close when mid-quiz
useEffect(() => { useEffect(() => {
if (!attemptId) return if (!attemptId) return
const handler = (e) => { e.preventDefault(); e.returnValue = 'You have an in-progress quiz. Your progress is saved.' } const msg = timeLeft !== null
? 'Your quiz is timed. If you close this tab, the timer will continue and auto-submit when time expires. Progress is saved.'
: 'You have an in-progress quiz. Your progress is saved.'
const handler = (e) => { e.preventDefault(); e.returnValue = msg }
window.addEventListener('beforeunload', handler) window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
}, [attemptId]) }, [attemptId, timeLeft])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
const [quizRes, voicesRes] = await Promise.all([ const [quizRes, voicesRes, favoritesRes] = await Promise.all([
api.get(`/quizzes/${id}`), api.get(`/quizzes/${id}`),
api.get('/tts/voices').catch(() => ({ data: [] })), api.get('/tts/voices').catch(() => ({ data: [] })),
api.get('/favorites').catch(() => ({ data: [] })),
]) ])
setQuiz(quizRes.data) setQuiz(quizRes.data)
setVoices(voicesRes.data) setVoices(voicesRes.data)
setFavorites(favoritesRes.data)
const def = voicesRes.data.find(v => v.is_default) const def = voicesRes.data.find(v => v.is_default)
if (def) setSelectedVoice(def.id) if (def) setSelectedVoice(def.id)
// Check for saved progress and auto-resume
try {
const progressRes = await api.get('/attempts/progress', { params: { quiz_id: id } })
if (progressRes.data) {
await resumeQuiz(progressRes.data)
}
} catch {}
} catch { navigate('/') } } catch { navigate('/') }
finally { setLoading(false) } finally { setLoading(false) }
} }
@ -216,6 +246,8 @@ export default function QuizPage() {
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`) const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id) setAttemptId(attemptRes.data.id)
const mins = timerMinutes || quizRes.data.time_limit_minutes const mins = timerMinutes || quizRes.data.time_limit_minutes
const now = new Date().toISOString()
setStartedAt(now)
if (mode === 'exam' && mins) { if (mode === 'exam' && mins) {
const secs = mins * 60 const secs = mins * 60
setTimeLeft(secs); setTotalTime(secs) setTimeLeft(secs); setTotalTime(secs)
@ -248,10 +280,12 @@ useEffect(() => {
mode: quizMode, mode: quizMode,
voice: selectedVoice || null, voice: selectedVoice || null,
time_left: timeLeft, time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
}).catch(() => {}) }).catch(() => {})
}, 1500) // debounce 1.5s }, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current) return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId, timeLeft]) }, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
@ -264,7 +298,7 @@ useEffect(() => {
await new Promise(r => setTimeout(r, 1200)) await new Promise(r => setTimeout(r, 1200))
} }
clearInterval(timerRef.current) clearInterval(timerRef.current)
api.delete(`/attempts/progress/${id}`).catch(() => {}) api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
setSubmitting(true) setSubmitting(true)
try { try {
const submission = { const submission = {
@ -281,37 +315,6 @@ useEffect(() => {
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null if (!quiz) return null
const resumeQuiz = async (saved) => {
const mode = saved.mode || saved.quizMode
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {}
// For study mode, load quiz with correct answers BEFORE showing
if (mode === 'study') {
try {
const quizRes = await api.get(`/quizzes/${id}?study=true`)
setQuiz(quizRes.data)
} catch { }
} else {
// For exam mode, ensure quiz data is loaded
try {
const quizRes = await api.get(`/quizzes/${id}`)
setQuiz(quizRes.data)
} catch { }
}
hasStarted.current = true
setQuizMode(mode)
setAnswers(savedAnswers)
setCurrentIdx(savedIdx)
setAttemptId(saved.attempt_id || saved.attemptId)
if (saved.voice) setSelectedVoice(saved.voice)
// Restore timer (paused time restarts from where it was)
if (saved.time_left != null && saved.time_left > 0) {
setTimeLeft(saved.time_left)
setTotalTime(saved.time_left) // use remaining as new total for progress bar
}
}
if (!quizMode) return ( if (!quizMode) return (
<div> <div>
@ -320,7 +323,7 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link> <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onResume={resumeQuiz} /> <ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
</div> </div>
) )
@ -331,6 +334,23 @@ useEffect(() => {
const totalCount = questions.length const totalCount = questions.length
const isLast = currentIdx === totalCount - 1 const isLast = currentIdx === totalCount - 1
const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId)
try {
if (isFavorited) {
await api.delete(`/favorites/${questionId}`)
setFavorites(prev => prev.filter(id => id !== questionId))
showToast('Removed from favorites')
} else {
await api.post('/favorites', { question_id: questionId })
setFavorites(prev => [...prev, questionId])
showToast('Added to favorites')
}
} catch (err) {
showToast(err.response?.data?.detail || 'Failed to update favorite')
}
}
const QuestionDot = ({ q, i }) => { const QuestionDot = ({ q, i }) => {
const isActive = i === currentIdx const isActive = i === currentIdx
const isDone = !!answers[q.id] const isDone = !!answers[q.id]
@ -350,15 +370,21 @@ useEffect(() => {
{/* In-app leave confirmation */} {/* In-app leave confirmation */}
{leaveTarget && ( {leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 380, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}> <div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 420, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div> <div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Leave quiz?</h2> <h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}> <p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
Your progress is saved resume from where you left off next time. Your progress is saved resume from where you left off.
{timeLeft !== null && (
<><br/><br/>
<strong style={{ color: '#f59e0b' }}> Timer continues:</strong> This quiz is timed.
If you leave, the timer keeps counting and will auto-submit when it expires.
</>
)}
</p> </p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button> <button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
<button className="btn btn-danger" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Leave</button> <button className="btn btn-primary" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Suspend &amp; Leave</button>
</div> </div>
</div> </div>
</div> </div>
@ -391,8 +417,11 @@ useEffect(() => {
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span> <span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />} {timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget('/')} title="Save progress and exit">
Suspend
</button>
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>} {isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div> </div>
</div> </div>
@ -418,7 +447,26 @@ useEffect(() => {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{current && ( {current && (
<div className="question-card"> <div className="question-card">
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
<button
onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.4rem',
padding: 4,
lineHeight: 1,
transition: 'transform 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.15)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
{favorites.includes(current.id) ? '⭐' : '☆'}
</button>
</div>
{voices.length > 0 && ( {voices.length > 0 && (
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<TTSButton <TTSButton

View file

@ -138,6 +138,10 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}> onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}>
{isModerator && ( {isModerator && (
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}> <div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
<Link to={`/quizzes/${quiz.id}/edit`} title="Edit quiz"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4, textDecoration: 'none', display: 'flex', alignItems: 'center' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></Link>
<button title={quiz.is_published === 0 ? 'Hidden from users — click to publish' : 'Visible — click to hide'} onClick={togglePublish} <button title={quiz.is_published === 0 ? 'Hidden from users — click to publish' : 'Visible — click to hide'} onClick={togglePublish}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: quiz.is_published === 0 ? '#ef4444' : '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: quiz.is_published === 0 ? '#ef4444' : '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}> onMouseEnter={e => e.currentTarget.style.opacity = '0.7'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}>

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext' import { useTheme } from '../context/ThemeContext'