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 |
| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector |
| 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 |
| TTS | OpenAI, AWS Polly, ElevenLabs |
| Queue | Celery + Redis |
@ -56,7 +56,7 @@ REDIS_URL=redis://redis:6379/0
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
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_API_KEY=<openai-api-key>
@ -88,6 +88,28 @@ MAX_UPLOAD_SIZE=524288000
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
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.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.scheduler import start_scheduler, stop_scheduler
@ -122,7 +122,7 @@ def setup_pgvector():
"""Enable pgvector, add new columns/tables, run schema migrations."""
from sqlalchemy import text
# 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:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
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")
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()
@ -260,8 +271,13 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
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
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(questions.router, prefix="/api/questions", tags=["questions"])
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")

View file

@ -6,6 +6,7 @@ from app.models.question import Question
from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite
__all__ = [
"User",
@ -17,4 +18,5 @@ __all__ = [
"AttemptAnswer",
"ReminderSchedule",
"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)
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")
attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
@property
def is_admin(self):

View file

@ -56,11 +56,12 @@ def create_user(
from app.models.email_verification import EmailVerification
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")
user = User(
email=user_data.email,
email=email_normalized,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
role="user",
@ -98,19 +99,15 @@ def list_available_models(
return result
class LiteLLMModelQuery(BaseModel):
api_key: str | None = None
api_base: str | None = None
@router.post("/litellm/models")
@router.get("/litellm/models")
def search_litellm_models(
body: LiteLLMModelQuery = LiteLLMModelQuery(),
api_key: str | None = None,
api_base: str | None = None,
admin: User = Depends(require_admin),
):
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = body.api_key or settings.LITELLM_API_KEY
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
key = api_key or settings.LITELLM_API_KEY
if base:
try:
@ -202,3 +199,66 @@ def delete_model(
raise HTTPException(status_code=404, detail="Model config not found")
db.delete(model)
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
mode: str
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")
@ -200,19 +202,23 @@ def save_progress(
data: ProgressSave,
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:
import redis as redis_lib, json as _json
from app.config import settings
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
"quiz_id": data.quiz_id,
"attempt_id": data.attempt_id,
"answers": data.answers,
"current_idx": data.current_idx,
"mode": data.mode,
"voice": data.voice,
"time_left": data.time_left,
"started_at": data.started_at,
"total_time": data.total_time,
}))
except Exception:
pass # Redis unavailable — degrade gracefully
@ -226,35 +232,74 @@ def get_progress(
current_user: User = Depends(get_current_user),
):
"""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:
import redis as redis_lib, json as _json
from app.config import settings
from datetime import datetime, timezone
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)
if not data:
return None
saved = _json.loads(data)
attempt_id = saved.get("attempt_id")
if attempt_id:
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
# Stale: attempt was submitted or doesn't exist
if not attempt or attempt.completed_at is not None:
r.delete(key)
return None
# Check if timer expired for timed quiz (auto-submit)
total_time = saved.get("total_time")
started_at_str = saved.get("started_at")
if total_time is not None and started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00'))
elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
if elapsed >= total_time:
# 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
except Exception:
pass
return None
@router.delete("/progress/{quiz_id}", status_code=204)
@router.delete("/progress/{attempt_id}", status_code=204)
def clear_progress(
quiz_id: int,
attempt_id: int,
current_user: User = Depends(get_current_user),
):
"""Clear saved progress when quiz is submitted."""
@ -262,7 +307,7 @@ def clear_progress(
import redis as redis_lib
from app.config import settings
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:
pass

View file

@ -43,7 +43,8 @@ RESET_WINDOW_HOURS = 1
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:
return # silently ignore unknown emails
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")
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:
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:
raise HTTPException(status_code=400, detail="Email already registered")
is_first_user = db.query(User).count() == 0
user = User(
email=user_data.email,
email=email_normalized,
hashed_password=get_password_hash(user_data.password),
name=user_data.name,
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"
_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):
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")
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:
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")
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
if not user:
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.quiz import Quiz
from app.models.user import User
from app.models.favorite import Favorite
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@ -40,6 +41,7 @@ def get_bank_ids(
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -51,6 +53,12 @@ def get_bank_ids(
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
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():
phrase = q.strip()
query = query.filter(
@ -68,6 +76,7 @@ def get_question_bank(
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200),
offset: int = Query(0),
@ -86,6 +95,13 @@ def get_question_bank(
if uncategorized:
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_ids_ordered: list[int] = []
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.attempt import QuizAttempt
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.utils.auth import get_current_user, require_moderator
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
@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")
def search_quizzes(
q: str = Query(..., min_length=2, max_length=200),
@ -274,6 +291,26 @@ def get_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)
def shuffle_quiz(
quiz_id: int,

View file

@ -18,19 +18,30 @@ class TTSRequest(BaseModel):
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")
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(
"""Return available TTS voices from DB, excluding Polly if disabled."""
query = 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
)
if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
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")
@ -46,6 +57,8 @@ def text_to_speech(
text = request.text[:2000]
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()
model_id = config.model_id if config else request.voice
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
class QuizUpdate(BaseModel):
title: str
class QuestionResponse(BaseModel):
id: int
question_text: str

View file

@ -1,8 +1,9 @@
"""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)
1. LiteLLM proxy model from Redis settings (overrides env)
2. LiteLLM proxy model from LITELLM_EMBEDDING_MODEL env
3. AWS Bedrock Titan Embed V2 (direct fallback)
"""
import logging
@ -11,6 +12,19 @@ from app.config import settings
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:
"""Build the text to embed for a question — stem + options, no explanation."""
parts = [question_text]
@ -30,20 +44,23 @@ def generate_embedding(text: str) -> list[float] | None:
if not clean:
return None
# ── 1. LiteLLM proxy (best quality) ────────────────────────
if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY:
# ── 1. LiteLLM proxy (direct httpx — avoids LiteLLM param validation) ──
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:
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,
import httpx, json as _json
body: dict = {"model": embedding_model, "input": [clean], "dimensions": settings.EMBEDDING_DIMENSIONS}
resp = httpx.post(
f"{api_base}/v1/embeddings",
headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
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:
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}")

View file

@ -7,20 +7,22 @@ _client = None
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:
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]
import httpx, json as _json
from app.services.embedding_service import _get_embedding_model
model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL
api_base = (settings.LITELLM_API_BASE or "").rstrip("/")
body = {"model": model, "input": input, "dimensions": settings.EMBEDDING_DIMENSIONS}
resp = httpx.post(
f"{api_base}/v1/embeddings",
headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
content=_json.dumps(body),
timeout=60,
)
resp.raise_for_status()
return [item["embedding"] for item in resp.json()["data"]]
def get_client() -> chromadb.PersistentClient:
@ -32,8 +34,10 @@ def get_client() -> chromadb.PersistentClient:
def get_or_create_collection(document_id: int):
client = get_client()
# 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)
from app.services.embedding_service import _get_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
kwargs = {"name": f"doc_{document_id}"}
if ef:

View file

@ -122,6 +122,9 @@ def extract_quiz(
if resolved_mode == "questions_only":
_push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.")
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}")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p)
@ -160,6 +163,9 @@ def extract_quiz(
if extraction_mode == "standard":
# ── STANDARD: existing working extraction (unchanged) ──────────────
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:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}{model_name}")
else:

View file

@ -1,10 +1,11 @@
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 jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
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:
to_encode = data.copy()
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)
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(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
@ -46,7 +55,7 @@ def get_current_user(
except JWTError:
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:
raise credentials_exception
@ -73,3 +82,38 @@ def require_moderator(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Moderator access required")
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 ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage'
function ProtectedRoute({ children, requireModerator = false }) {
const { user, loading } = useAuth()
@ -66,6 +67,7 @@ function AppRoutes() {
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
<Footer />

View file

@ -13,8 +13,21 @@ api.interceptors.request.use((config) => {
})
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) => {
// 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 || ''
// Don't redirect to login from the login/register/verify endpoints themselves
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 jobs = res.data || []
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)
// Poll faster when jobs running, slower when idle
clearInterval(interval)

View file

@ -11,6 +11,7 @@ export default function AdminPage() {
const [tab, setTab] = useState('models')
const [users, setUsers] = useState([])
const [models, setModels] = useState([])
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
@ -26,6 +27,14 @@ export default function AdminPage() {
const [searchError, setSearchError] = 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(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
loadData()
@ -34,12 +43,14 @@ export default function AdminPage() {
const loadData = async () => {
setLoading(true)
try {
const [usersRes, modelsRes] = await Promise.all([
const [usersRes, modelsRes, settingsRes] = await Promise.all([
api.get('/admin/users'),
api.get('/admin/models'),
api.get('/admin/settings'),
])
setUsers(usersRes.data)
setModels(modelsRes.data)
setSettings(settingsRes.data)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load data')
} 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) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
setSearchResults([])
@ -139,9 +190,9 @@ export default function AdminPage() {
<div className="card">
<h2>Admin Dashboard</h2>
<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)}>
{t === 'models' ? 'AI Models' : 'Users'}
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
</button>
))}
</div>
@ -237,11 +288,11 @@ export default function AdminPage() {
) : (
modelsByTask[task].map(m => (
<div className="section-item" key={m.id}>
<div>
<div style={{ minWidth: 0 }}>
<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 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 && (
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
@ -351,6 +402,144 @@ export default function AdminPage() {
</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>
)
}

View file

@ -63,6 +63,17 @@ function JobDetail({ job }) {
{job.quiz_id && (
<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)}>
{expanded ? 'Hide' : 'Details'}
</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 [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false)
const [showFavorites, setShowFavorites] = useState(false)
const [favorites, setFavorites] = useState([])
const [offset, setOffset] = useState(0)
const [studyQuestion, setStudyQuestion] = useState(null)
const [editQuestion, setEditQuestion] = useState(null)
@ -244,15 +246,17 @@ export default function QuestionBankPage() {
useEffect(() => {
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)
try {
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true
if (favOnly) params.favorites_only = true
const res = await api.get('/questions/bank', { params })
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
setTotal(res.data.total)
@ -262,9 +266,9 @@ export default function QuestionBankPage() {
useEffect(() => {
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)
}, [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 })
@ -275,6 +279,7 @@ export default function QuestionBankPage() {
if (searchQuery.trim()) params.q = searchQuery.trim()
if (filterCatId) params.category_id = parseInt(filterCatId)
if (showUncategorized) params.uncategorized = true
if (showFavorites) params.favorites_only = true
const res = await api.get('/questions/bank/ids', { params })
setSelectedIds(new Set(res.data))
} catch {
@ -326,7 +331,7 @@ export default function QuestionBankPage() {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized)
loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites)
} catch { }
}
@ -335,6 +340,21 @@ export default function QuestionBankPage() {
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 (
<div>
{/* Delete category dialog */}
@ -398,26 +418,26 @@ export default function QuestionBankPage() {
</div>
{/* Category filter chips */}
{categories.length > 0 && (
<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>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId('') }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false) }}>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
))}
</div>
)}
<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>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowFavorites(v => !v); setFilterCatId(''); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId(''); setShowFavorites(false) }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false); setShowFavorites(false) }}>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
))}
</div>
{/* Search row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
@ -514,6 +534,20 @@ export default function QuestionBankPage() {
)}
</div>
<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>
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
</div>
@ -526,7 +560,7 @@ export default function QuestionBankPage() {
{questions.length < total && (
<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)`}
</button>
</div>

View file

@ -155,6 +155,9 @@ export default function QuizEditPage() {
const [questions, setQuestions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
const [savingTitle, setSavingTitle] = useState(false)
useEffect(() => {
Promise.all([
@ -162,6 +165,7 @@ export default function QuizEditPage() {
api.get(`/quizzes/${id}/questions`),
]).then(([qRes, questRes]) => {
setQuiz(qRes.data)
setTitleValue(qRes.data.title)
setQuestions(questRes.data)
}).catch(() => navigate('/quizzes'))
.finally(() => setLoading(false))
@ -178,16 +182,65 @@ export default function QuizEditPage() {
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 (!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>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<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 style={{ display: 'flex', gap: 8 }}>
<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 [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 (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -91,19 +84,6 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
{quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
</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>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
@ -165,6 +145,8 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false)
const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([])
const timerRef = useRef(null)
const toastRef = useRef(null)
const hasStarted = useRef(false)
@ -177,25 +159,73 @@ export default function QuizPage() {
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
useEffect(() => {
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)
return () => window.removeEventListener('beforeunload', handler)
}, [attemptId])
}, [attemptId, timeLeft])
useEffect(() => {
const load = async () => {
try {
const [quizRes, voicesRes] = await Promise.all([
const [quizRes, voicesRes, favoritesRes] = await Promise.all([
api.get(`/quizzes/${id}`),
api.get('/tts/voices').catch(() => ({ data: [] })),
api.get('/favorites').catch(() => ({ data: [] })),
])
setQuiz(quizRes.data)
setVoices(voicesRes.data)
setFavorites(favoritesRes.data)
const def = voicesRes.data.find(v => v.is_default)
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('/') }
finally { setLoading(false) }
}
@ -216,6 +246,8 @@ export default function QuizPage() {
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
const mins = timerMinutes || quizRes.data.time_limit_minutes
const now = new Date().toISOString()
setStartedAt(now)
if (mode === 'exam' && mins) {
const secs = mins * 60
setTimeLeft(secs); setTotalTime(secs)
@ -248,10 +280,12 @@ useEffect(() => {
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
}).catch(() => {})
}, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId, timeLeft])
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
@ -264,7 +298,7 @@ useEffect(() => {
await new Promise(r => setTimeout(r, 1200))
}
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${id}`).catch(() => {})
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
setSubmitting(true)
try {
const submission = {
@ -281,37 +315,6 @@ useEffect(() => {
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
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 (
<div>
@ -320,7 +323,7 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div>
)}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onResume={resumeQuiz} />
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
</div>
)
@ -331,6 +334,23 @@ useEffect(() => {
const totalCount = questions.length
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 isActive = i === currentIdx
const isDone = !!answers[q.id]
@ -350,15 +370,21 @@ useEffect(() => {
{/* In-app leave confirmation */}
{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={{ 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={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Leave quiz?</h2>
<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>
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
Your progress is saved resume from where you left off 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>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<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>
@ -391,8 +417,11 @@ useEffect(() => {
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
</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} />}
<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>}
</div>
</div>
@ -418,7 +447,26 @@ useEffect(() => {
<div style={{ flex: 1, minWidth: 0 }}>
{current && (
<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 && (
<div style={{ marginBottom: 10 }}>
<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' }}>
{isModerator && (
<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}
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'}>

View file

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