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:
parent
c94640c890
commit
12d99d3609
30 changed files with 961 additions and 181 deletions
26
README.md
26
README.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
16
backend/app/models/favorite.py
Normal file
16
backend/app/models/favorite.py
Normal 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")
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
73
backend/app/routers/favorites.py
Normal file
73
backend/app/routers/favorites.py
Normal 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]
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
16
backend/app/schemas/favorite.py
Normal file
16
backend/app/schemas/favorite.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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') ||
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
14
frontend/src/pages/NotFoundPage.jsx
Normal file
14
frontend/src/pages/NotFoundPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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
|
||||||
|
|
|
||||||
|
|
@ -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'}>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue