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 |
|
||||
| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector |
|
||||
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock) |
|
||||
| Embeddings | AWS Bedrock Titan Embed V2 via LiteLLM proxy |
|
||||
| Embeddings | Configurable via Admin UI or `LITELLM_EMBEDDING_MODEL` env (default: `ge-gemini-embedding-001`) |
|
||||
| Document vectors | ChromaDB |
|
||||
| TTS | OpenAI, AWS Polly, ElevenLabs |
|
||||
| Queue | Celery + Redis |
|
||||
|
|
@ -56,7 +56,7 @@ REDIS_URL=redis://redis:6379/0
|
|||
LITELLM_MODEL=openai/claude-haiku-4.5
|
||||
LITELLM_API_KEY=<your-litellm-or-openai-key>
|
||||
LITELLM_API_BASE=https://your-litellm-proxy.com # or leave empty for direct OpenAI
|
||||
LITELLM_EMBEDDING_MODEL=openai/titan-embed-v2
|
||||
LITELLM_EMBEDDING_MODEL=ge-gemini-embedding-001 # model name as proxy knows it, no prefix needed
|
||||
|
||||
# OpenAI (for TTS — uses api.openai.com directly, not proxy)
|
||||
OPENAI_API_KEY=<openai-api-key>
|
||||
|
|
@ -88,6 +88,28 @@ MAX_UPLOAD_SIZE=524288000
|
|||
CHROMA_PERSIST_DIR=/app/chroma_data
|
||||
```
|
||||
|
||||
## Rebuild & Restart
|
||||
|
||||
```bash
|
||||
# Rebuild and restart everything
|
||||
docker compose build && docker compose up -d
|
||||
|
||||
# Rebuild and restart a single service (backend, frontend, or celery)
|
||||
docker compose build backend && docker compose up -d backend
|
||||
docker compose build frontend && docker compose up -d frontend
|
||||
docker compose build celery && docker compose up -d celery
|
||||
|
||||
# Restart without rebuilding (picks up .env changes, not code changes)
|
||||
docker compose restart backend
|
||||
docker compose restart frontend
|
||||
|
||||
# View logs
|
||||
docker compose logs backend --tail=50
|
||||
docker compose logs celery --tail=50
|
||||
```
|
||||
|
||||
> **Note:** Frontend and backend are built into Docker images — code changes require a `build` before they take effect. Only `.env` changes and volume-mounted data (uploads, chroma, postgres) are picked up by a plain `restart`.
|
||||
|
||||
## CLI Management
|
||||
|
||||
All commands run inside the backend container:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ def setup_pgvector():
|
|||
"""Enable pgvector, add new columns/tables, run schema migrations."""
|
||||
from sqlalchemy import text
|
||||
# Import new models so create_all picks them up
|
||||
from app.models import quiz_category, quiz_question_link, question_category # noqa
|
||||
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
|
||||
|
|
@ -193,6 +193,17 @@ def setup_pgvector():
|
|||
"""))
|
||||
# Make quiz_id on questions nullable (it becomes informational "source_quiz_id")
|
||||
conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL"))
|
||||
|
||||
# Favorites table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, question_id)
|
||||
)
|
||||
"""))
|
||||
conn.commit()
|
||||
conn.commit()
|
||||
|
||||
|
|
@ -260,8 +271,13 @@ app.add_middleware(
|
|||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-New-Token"], # Allow frontend to read this header
|
||||
)
|
||||
|
||||
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
|
||||
from app.utils.auth import TokenRefreshMiddleware
|
||||
app.add_middleware(TokenRefreshMiddleware)
|
||||
|
||||
# Serve uploaded images as static files
|
||||
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
|
||||
|
||||
|
|
@ -275,6 +291,7 @@ app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]
|
|||
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
|
||||
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
|
||||
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
|
||||
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from app.models.question import Question
|
|||
from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||
from app.models.reminder import ReminderSchedule
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.models.favorite import Favorite
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
|
|
@ -17,4 +18,5 @@ __all__ = [
|
|||
"AttemptAnswer",
|
||||
"ReminderSchedule",
|
||||
"AIModelConfig",
|
||||
"Favorite",
|
||||
]
|
||||
|
|
|
|||
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)
|
||||
|
||||
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")
|
||||
attempts = relationship("QuizAttempt", back_populates="user")
|
||||
reminders = relationship("ReminderSchedule", back_populates="user")
|
||||
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
|
|
|
|||
|
|
@ -56,11 +56,12 @@ def create_user(
|
|||
from app.models.email_verification import EmailVerification
|
||||
from datetime import datetime
|
||||
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
email_normalized = user_data.email.lower().strip()
|
||||
if db.query(User).filter(User.email == email_normalized).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
email=email_normalized,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role="user",
|
||||
|
|
@ -98,19 +99,15 @@ def list_available_models(
|
|||
return result
|
||||
|
||||
|
||||
class LiteLLMModelQuery(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
@router.post("/litellm/models")
|
||||
@router.get("/litellm/models")
|
||||
def search_litellm_models(
|
||||
body: LiteLLMModelQuery = LiteLLMModelQuery(),
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
|
||||
base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = body.api_key or settings.LITELLM_API_KEY
|
||||
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
|
||||
if base:
|
||||
try:
|
||||
|
|
@ -202,3 +199,66 @@ def delete_model(
|
|||
raise HTTPException(status_code=404, detail="Model config not found")
|
||||
db.delete(model)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- System Settings ---
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings(admin: User = Depends(require_admin)):
|
||||
"""Get system settings."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
registration_enabled = r.get("settings:registration_enabled")
|
||||
embedding_model = r.get("settings:embedding_model")
|
||||
polly_enabled = r.get("settings:polly_enabled")
|
||||
return {
|
||||
"registration_enabled": registration_enabled != "false",
|
||||
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": polly_enabled != "false",
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"registration_enabled": True,
|
||||
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_settings(
|
||||
settings_data: dict,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Update system settings."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
if "registration_enabled" in settings_data:
|
||||
value = "true" if settings_data["registration_enabled"] else "false"
|
||||
r.set("settings:registration_enabled", value)
|
||||
|
||||
if "embedding_model" in settings_data:
|
||||
r.set("settings:embedding_model", settings_data["embedding_model"])
|
||||
|
||||
if "polly_enabled" in settings_data:
|
||||
value = "true" if settings_data["polly_enabled"] else "false"
|
||||
r.set("settings:polly_enabled", value)
|
||||
|
||||
return {"success": True, "message": "Settings updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/embedding/test")
|
||||
def test_embedding(admin: User = Depends(require_admin)):
|
||||
"""Test the currently configured embedding model."""
|
||||
from app.services.embedding_service import generate_embedding, _get_embedding_model
|
||||
model = _get_embedding_model()
|
||||
if not model:
|
||||
raise HTTPException(status_code=400, detail="No embedding model configured")
|
||||
result = generate_embedding("The quick brown fox jumps over the lazy dog")
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
|
||||
return {"model": model, "dimensions": len(result), "status": "ok"}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,9 @@ class ProgressSave(BaseModel):
|
|||
current_idx: int
|
||||
mode: str
|
||||
voice: str | None = None
|
||||
time_left: int | None = None # remaining seconds for timed mode
|
||||
time_left: int | None = None # remaining seconds for timed mode (informational, not authoritative)
|
||||
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
|
||||
total_time: int | None = None # original time limit in seconds
|
||||
|
||||
|
||||
@router.post("/progress")
|
||||
|
|
@ -200,19 +202,23 @@ def save_progress(
|
|||
data: ProgressSave,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Save in-progress quiz answers to Redis (survives logout/browser change)."""
|
||||
"""Save in-progress quiz answers to Redis (survives logout/browser change).
|
||||
Each attempt gets its own saved progress (key includes attempt_id)."""
|
||||
try:
|
||||
import redis as redis_lib, json as _json
|
||||
from app.config import settings
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
key = f"quiz_progress:{current_user.id}:{data.quiz_id}"
|
||||
key = f"quiz_progress:{current_user.id}:{data.attempt_id}"
|
||||
r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days
|
||||
"quiz_id": data.quiz_id,
|
||||
"attempt_id": data.attempt_id,
|
||||
"answers": data.answers,
|
||||
"current_idx": data.current_idx,
|
||||
"mode": data.mode,
|
||||
"voice": data.voice,
|
||||
"time_left": data.time_left,
|
||||
"started_at": data.started_at,
|
||||
"total_time": data.total_time,
|
||||
}))
|
||||
except Exception:
|
||||
pass # Redis unavailable — degrade gracefully
|
||||
|
|
@ -226,35 +232,74 @@ def get_progress(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Retrieve in-progress quiz answers from Redis.
|
||||
Clears stale data if the saved attempt has already been submitted."""
|
||||
Finds the latest incomplete attempt for this quiz, then checks Redis.
|
||||
Clears stale data if the saved attempt has already been submitted.
|
||||
Auto-submits timed quizzes if timer has expired."""
|
||||
try:
|
||||
import redis as redis_lib, json as _json
|
||||
from app.config import settings
|
||||
from datetime import datetime, timezone
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
key = f"quiz_progress:{current_user.id}:{quiz_id}"
|
||||
|
||||
# Find latest incomplete attempt for this quiz
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.quiz_id == quiz_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.is_(None),
|
||||
).order_by(QuizAttempt.started_at.desc()).first()
|
||||
|
||||
if not attempt:
|
||||
return None
|
||||
|
||||
key = f"quiz_progress:{current_user.id}:{attempt.id}"
|
||||
data = r.get(key)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
saved = _json.loads(data)
|
||||
attempt_id = saved.get("attempt_id")
|
||||
if attempt_id:
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
).first()
|
||||
# Stale: attempt was submitted or doesn't exist
|
||||
if not attempt or attempt.completed_at is not None:
|
||||
r.delete(key)
|
||||
return None
|
||||
|
||||
# Check if timer expired for timed quiz (auto-submit)
|
||||
total_time = saved.get("total_time")
|
||||
started_at_str = saved.get("started_at")
|
||||
if total_time is not None and started_at_str:
|
||||
try:
|
||||
started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00'))
|
||||
elapsed = (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||
if elapsed >= total_time:
|
||||
# Timer expired — auto-submit
|
||||
questions = {q.id: q for q in get_quiz_questions(db, quiz_id)}
|
||||
score = 0
|
||||
submitted_answers = saved.get("answers", {})
|
||||
for qid_str, user_ans in submitted_answers.items():
|
||||
qid = int(qid_str)
|
||||
question = questions.get(qid)
|
||||
if question:
|
||||
correct = (question.correct_answer or "").strip().lower()
|
||||
if (user_ans or "").strip().lower() == correct:
|
||||
score += 1
|
||||
db.add(AttemptAnswer(
|
||||
attempt_id=attempt.id,
|
||||
question_id=qid,
|
||||
user_answer=user_ans,
|
||||
is_correct=(user_ans or "").strip().lower() == correct,
|
||||
))
|
||||
attempt.score = score
|
||||
attempt.completed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
r.delete(key)
|
||||
return None # no progress to resume — already submitted
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return saved
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/progress/{quiz_id}", status_code=204)
|
||||
@router.delete("/progress/{attempt_id}", status_code=204)
|
||||
def clear_progress(
|
||||
quiz_id: int,
|
||||
attempt_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Clear saved progress when quiz is submitted."""
|
||||
|
|
@ -262,7 +307,7 @@ def clear_progress(
|
|||
import redis as redis_lib
|
||||
from app.config import settings
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
r.delete(f"quiz_progress:{current_user.id}:{quiz_id}")
|
||||
r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ RESET_WINDOW_HOURS = 1
|
|||
|
||||
|
||||
def _check_reset_rate_limit(db: Session, email: str):
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
email_normalized = email.lower().strip()
|
||||
user = db.query(User).filter(User.email == email_normalized).first()
|
||||
if not user:
|
||||
return # silently ignore unknown emails
|
||||
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
|
||||
|
|
@ -60,16 +61,31 @@ def _check_reset_rate_limit(db: Session, email: str):
|
|||
|
||||
@router.post("/register")
|
||||
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
# Check if registration is enabled (unless this is the first user - always allow admin creation)
|
||||
is_first_user = db.query(User).count() == 0
|
||||
if not is_first_user:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
from app.config import settings
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||
registration_enabled = r.get("settings:registration_enabled")
|
||||
# Default to enabled if not set, disabled if explicitly set to "false"
|
||||
if registration_enabled == "false":
|
||||
raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Redis unavailable — allow registration (fail open)
|
||||
|
||||
if len(user_data.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
email_normalized = user_data.email.lower().strip()
|
||||
existing = db.query(User).filter(User.email == email_normalized).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
is_first_user = db.query(User).count() == 0
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
email=email_normalized,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role="admin" if is_first_user else "user",
|
||||
|
|
@ -105,7 +121,8 @@ def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Requ
|
|||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_login_rate_limit(client_ip)
|
||||
|
||||
user = db.query(User).filter(User.email == login_data.email).first()
|
||||
email_normalized = login_data.email.lower().strip()
|
||||
user = db.query(User).filter(User.email == email_normalized).first()
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
|
|
@ -138,7 +155,8 @@ def verify_email(token: str, db: Session = Depends(get_db)):
|
|||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == data.email).first()
|
||||
email_normalized = data.email.lower().strip()
|
||||
user = db.query(User).filter(User.email == email_normalized).first()
|
||||
if not user:
|
||||
return {"message": "If that email exists, a verification link has been sent."}
|
||||
|
||||
|
|
@ -160,9 +178,10 @@ async def resend_verification(data: ForgotPasswordRequest, background_tasks: Bac
|
|||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
_check_reset_rate_limit(db, data.email)
|
||||
email_normalized = data.email.lower().strip()
|
||||
_check_reset_rate_limit(db, email_normalized)
|
||||
|
||||
user = db.query(User).filter(User.email == data.email).first()
|
||||
user = db.query(User).filter(User.email == email_normalized).first()
|
||||
# Always return same message to avoid email enumeration
|
||||
if not user:
|
||||
return {"message": "If that email is registered, a reset link has been sent."}
|
||||
|
|
|
|||
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.quiz import Quiz
|
||||
from app.models.user import User
|
||||
from app.models.favorite import Favorite
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -40,6 +41,7 @@ def get_bank_ids(
|
|||
quiz_id: int | None = Query(None),
|
||||
category_id: int | None = Query(None),
|
||||
uncategorized: bool = Query(False),
|
||||
favorites_only: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
|
|
@ -51,6 +53,12 @@ def get_bank_ids(
|
|||
query = query.filter(Question.question_category_id == category_id)
|
||||
if uncategorized:
|
||||
query = query.filter(Question.question_category_id.is_(None))
|
||||
if favorites_only:
|
||||
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
||||
fav_ids = [f[0] for f in favorite_ids]
|
||||
if not fav_ids:
|
||||
return []
|
||||
query = query.filter(Question.id.in_(fav_ids))
|
||||
if q and q.strip():
|
||||
phrase = q.strip()
|
||||
query = query.filter(
|
||||
|
|
@ -68,6 +76,7 @@ def get_question_bank(
|
|||
quiz_id: int | None = Query(None),
|
||||
category_id: int | None = Query(None),
|
||||
uncategorized: bool = Query(False),
|
||||
favorites_only: bool = Query(False),
|
||||
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0),
|
||||
|
|
@ -86,6 +95,13 @@ def get_question_bank(
|
|||
if uncategorized:
|
||||
query = query.filter(Question.question_category_id.is_(None))
|
||||
|
||||
if favorites_only:
|
||||
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
||||
fav_ids = [f[0] for f in favorite_ids]
|
||||
if not fav_ids:
|
||||
return {"total": 0, "questions": []}
|
||||
query = query.filter(Question.id.in_(fav_ids))
|
||||
|
||||
# ── Semantic search (pgvector) ─────────────────────────────────
|
||||
semantic_ids_ordered: list[int] = []
|
||||
if q and q.strip() and search_mode in ("semantic", "hybrid"):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from app.models.question import Question as QuestionModel
|
|||
from app.models.section import Section
|
||||
from app.models.attempt import QuizAttempt
|
||||
from app.models.user import User
|
||||
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
|
||||
from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
|
||||
from app.services import quiz_service
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz
|
||||
|
|
@ -125,6 +125,23 @@ def get_extraction_job(job_id: str, current_user: User = Depends(require_moderat
|
|||
return result
|
||||
|
||||
|
||||
@router.post("/job/{job_id}/cancel")
|
||||
def cancel_extraction_job(job_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Cancel a running extraction job."""
|
||||
import redis as redis_lib
|
||||
from app.config import settings
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
# Verify this job belongs to the user
|
||||
user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 19)
|
||||
if job_id not in user_jobs:
|
||||
raise HTTPException(status_code=403, detail="Job not found")
|
||||
status = r.get(f"extraction:status:{job_id}")
|
||||
if status != "running":
|
||||
raise HTTPException(status_code=400, detail=f"Job is not running (status: {status})")
|
||||
r.set(f"extraction:status:{job_id}", "cancelled")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_quizzes(
|
||||
q: str = Query(..., min_length=2, max_length=200),
|
||||
|
|
@ -274,6 +291,26 @@ def get_quiz(
|
|||
return QuizDetail.model_validate(quiz)
|
||||
|
||||
|
||||
@router.patch("/{quiz_id}", response_model=QuizResponse)
|
||||
def update_quiz(
|
||||
quiz_id: int,
|
||||
data: QuizUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Update quiz metadata (title, etc.) — moderator only."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
|
||||
if data.title:
|
||||
quiz.title = data.title.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(quiz)
|
||||
return quiz
|
||||
|
||||
|
||||
@router.post("/{quiz_id}/shuffle", response_model=QuizDetail)
|
||||
def shuffle_quiz(
|
||||
quiz_id: int,
|
||||
|
|
|
|||
|
|
@ -18,19 +18,30 @@ class TTSRequest(BaseModel):
|
|||
voice: str | None = None # model_id override
|
||||
|
||||
|
||||
def _polly_enabled() -> bool:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
val = r.get("settings:polly_enabled")
|
||||
return val != "false"
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
def get_voices(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return available TTS voices from DB + env fallback."""
|
||||
db_models = db.query(AIModelConfig).filter(
|
||||
"""Return available TTS voices from DB, excluding Polly if disabled."""
|
||||
query = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||
|
||||
voices = [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
||||
return voices
|
||||
)
|
||||
if not _polly_enabled():
|
||||
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
|
||||
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
||||
|
||||
|
||||
@router.post("/speak")
|
||||
|
|
@ -46,6 +57,8 @@ def text_to_speech(
|
|||
text = request.text[:2000]
|
||||
|
||||
if request.voice:
|
||||
if request.voice.startswith("polly/") and not _polly_enabled():
|
||||
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
||||
model_id = config.model_id if config else request.voice
|
||||
api_key = config.api_key if (config and config.api_key) else None
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
class QuizUpdate(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class QuestionResponse(BaseModel):
|
||||
id: int
|
||||
question_text: str
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"""Embedding generation for semantic search via pgvector.
|
||||
|
||||
Priority:
|
||||
1. AWS Bedrock Titan Embed V2 (direct, best quality, 1024 dims)
|
||||
2. LiteLLM proxy with configured LITELLM_EMBEDDING_MODEL (fallback)
|
||||
1. LiteLLM proxy — model from Redis settings (overrides env)
|
||||
2. LiteLLM proxy — model from LITELLM_EMBEDDING_MODEL env
|
||||
3. AWS Bedrock Titan Embed V2 (direct fallback)
|
||||
"""
|
||||
import logging
|
||||
|
||||
|
|
@ -11,6 +12,19 @@ from app.config import settings
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_embedding_model() -> str:
|
||||
"""Return the active embedding model: Redis setting takes precedence over env."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
model = r.get("settings:embedding_model")
|
||||
if model:
|
||||
return model
|
||||
except Exception:
|
||||
pass
|
||||
return settings.LITELLM_EMBEDDING_MODEL
|
||||
|
||||
|
||||
def _text_for_question(question_text: str, options: list[str] | None) -> str:
|
||||
"""Build the text to embed for a question — stem + options, no explanation."""
|
||||
parts = [question_text]
|
||||
|
|
@ -30,20 +44,23 @@ def generate_embedding(text: str) -> list[float] | None:
|
|||
if not clean:
|
||||
return None
|
||||
|
||||
# ── 1. LiteLLM proxy (best quality) ────────────────────────
|
||||
if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY:
|
||||
# ── 1. LiteLLM proxy (direct httpx — avoids LiteLLM param validation) ──
|
||||
embedding_model = _get_embedding_model()
|
||||
api_base = (settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
if embedding_model and settings.LITELLM_API_KEY and api_base:
|
||||
try:
|
||||
import litellm
|
||||
resp = litellm.embedding(
|
||||
model=settings.LITELLM_EMBEDDING_MODEL,
|
||||
input=[clean],
|
||||
api_key=settings.LITELLM_API_KEY,
|
||||
api_base=settings.LITELLM_API_BASE or None,
|
||||
import httpx, json as _json
|
||||
body: dict = {"model": embedding_model, "input": [clean], "dimensions": settings.EMBEDDING_DIMENSIONS}
|
||||
resp = httpx.post(
|
||||
f"{api_base}/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
|
||||
content=_json.dumps(body),
|
||||
timeout=30,
|
||||
)
|
||||
emb = resp.data[0]["embedding"]
|
||||
resp.raise_for_status()
|
||||
emb = resp.json()["data"][0]["embedding"]
|
||||
if len(emb) == settings.EMBEDDING_DIMENSIONS:
|
||||
return emb
|
||||
# Dimension mismatch — don't store incompatible vector
|
||||
logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}")
|
||||
except Exception as e:
|
||||
logger.warning(f"LiteLLM embedding failed: {e}")
|
||||
|
|
|
|||
|
|
@ -7,20 +7,22 @@ _client = None
|
|||
|
||||
|
||||
class LiteLLMEmbeddingFunction(EmbeddingFunction):
|
||||
"""ChromaDB embedding function backed by LiteLLM."""
|
||||
"""ChromaDB embedding function backed by the LiteLLM proxy (direct httpx)."""
|
||||
|
||||
def __call__(self, input: list[str]) -> Embeddings:
|
||||
import litellm
|
||||
kwargs = {
|
||||
"model": settings.LITELLM_EMBEDDING_MODEL,
|
||||
"input": input,
|
||||
}
|
||||
if settings.LITELLM_API_KEY:
|
||||
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
response = litellm.embedding(**kwargs)
|
||||
return [item["embedding"] for item in response.data]
|
||||
import httpx, json as _json
|
||||
from app.services.embedding_service import _get_embedding_model
|
||||
model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL
|
||||
api_base = (settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
body = {"model": model, "input": input, "dimensions": settings.EMBEDDING_DIMENSIONS}
|
||||
resp = httpx.post(
|
||||
f"{api_base}/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"},
|
||||
content=_json.dumps(body),
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return [item["embedding"] for item in resp.json()["data"]]
|
||||
|
||||
|
||||
def get_client() -> chromadb.PersistentClient:
|
||||
|
|
@ -32,8 +34,10 @@ def get_client() -> chromadb.PersistentClient:
|
|||
|
||||
def get_or_create_collection(document_id: int):
|
||||
client = get_client()
|
||||
# Only use custom embedding if model is configured and has a provider prefix
|
||||
use_ef = bool(settings.LITELLM_EMBEDDING_MODEL and "/" in settings.LITELLM_EMBEDDING_MODEL)
|
||||
from app.services.embedding_service import _get_embedding_model
|
||||
active_model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL
|
||||
# Only use custom embedding if model and API base are configured
|
||||
use_ef = bool(active_model and settings.LITELLM_API_BASE)
|
||||
ef = LiteLLMEmbeddingFunction() if use_ef else None
|
||||
kwargs = {"name": f"doc_{document_id}"}
|
||||
if ef:
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ def extract_quiz(
|
|||
if resolved_mode == "questions_only":
|
||||
_push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.")
|
||||
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
||||
if r.get(f"extraction:status:{job_id}") == "cancelled":
|
||||
_push_step(r, job_id, "cancelled", "Job cancelled.")
|
||||
return
|
||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…")
|
||||
chunk_content = vector_service.get_pages_text(
|
||||
document_id=section.document_id, start_page=start_p, end_page=end_p)
|
||||
|
|
@ -160,6 +163,9 @@ def extract_quiz(
|
|||
if extraction_mode == "standard":
|
||||
# ── STANDARD: existing working extraction (unchanged) ──────────────
|
||||
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
||||
if r.get(f"extraction:status:{job_id}") == "cancelled":
|
||||
_push_step(r, job_id, "cancelled", "Job cancelled.")
|
||||
return
|
||||
if n_chunks > 1:
|
||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} → {model_name}…")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Request, Response
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
|
|
@ -25,10 +26,18 @@ def get_password_hash(password: str) -> str:
|
|||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
to_encode.update({"exp": expire, "iat": datetime.utcnow().timestamp()})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str):
|
||||
"""Decode JWT token and return payload, or None if invalid."""
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -46,7 +55,7 @@ def get_current_user(
|
|||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
user = db.query(User).filter(User.email == email.lower().strip()).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
|
|
@ -73,3 +82,38 @@ def require_moderator(current_user: User = Depends(get_current_user)) -> User:
|
|||
if not current_user.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="Moderator access required")
|
||||
return current_user
|
||||
|
||||
|
||||
class TokenRefreshMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to refresh JWT token if it's older than 12 hours or expires soon (sliding expiration)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Extract token from Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
new_token = None
|
||||
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
payload = decode_token(token)
|
||||
if payload:
|
||||
iat = payload.get("iat")
|
||||
exp = payload.get("exp")
|
||||
now = datetime.utcnow().timestamp()
|
||||
|
||||
if iat and exp:
|
||||
age_hours = (now - iat) / 3600
|
||||
time_until_expiry_hours = (exp - now) / 3600
|
||||
|
||||
# Refresh if: token > 12 hours old OR will expire in < 1 hour
|
||||
if age_hours > 12 or time_until_expiry_hours < 1:
|
||||
email = payload.get("sub")
|
||||
if email:
|
||||
new_token = create_access_token({"sub": email})
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Add new token to response headers if generated
|
||||
if new_token:
|
||||
response.headers["X-New-Token"] = new_token
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import QuizEditPage from './pages/QuizEditPage'
|
|||
import VerifyEmailPage from './pages/VerifyEmailPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
|
||||
function ProtectedRoute({ children, requireModerator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
|
@ -66,6 +67,7 @@ function AppRoutes() {
|
|||
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
|
||||
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
|
||||
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -13,8 +13,21 @@ api.interceptors.request.use((config) => {
|
|||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(response) => {
|
||||
// Sliding token expiration: if server sends new token, update localStorage
|
||||
const newToken = response.headers['x-new-token']
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// Check for token refresh even on error responses
|
||||
const newToken = error.response?.headers?.['x-new-token']
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
const url = error.config?.url || ''
|
||||
// Don't redirect to login from the login/register/verify endpoints themselves
|
||||
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') ||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function JobsBadge() {
|
|||
const res = await api.get('/quizzes/jobs')
|
||||
const jobs = res.data || []
|
||||
setAllJobs(jobs)
|
||||
const running = jobs.filter(j => j.status !== 'completed' && j.status !== 'failed')
|
||||
const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
|
||||
setActiveJobs(running)
|
||||
// Poll faster when jobs running, slower when idle
|
||||
clearInterval(interval)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default function AdminPage() {
|
|||
const [tab, setTab] = useState('models')
|
||||
const [users, setUsers] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
|
@ -26,6 +27,14 @@ export default function AdminPage() {
|
|||
const [searchError, setSearchError] = useState('')
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
|
||||
// Embedding model search state (in settings tab)
|
||||
const [embedSearchResults, setEmbedSearchResults] = useState([])
|
||||
const [embedSearchLoading, setEmbedSearchLoading] = useState(false)
|
||||
const [embedSearchError, setEmbedSearchError] = useState('')
|
||||
const [embedSearchFilter, setEmbedSearchFilter] = useState('')
|
||||
const [embedTestResult, setEmbedTestResult] = useState(null)
|
||||
const [embedTestLoading, setEmbedTestLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
|
||||
loadData()
|
||||
|
|
@ -34,12 +43,14 @@ export default function AdminPage() {
|
|||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [usersRes, modelsRes] = await Promise.all([
|
||||
const [usersRes, modelsRes, settingsRes] = await Promise.all([
|
||||
api.get('/admin/users'),
|
||||
api.get('/admin/models'),
|
||||
api.get('/admin/settings'),
|
||||
])
|
||||
setUsers(usersRes.data)
|
||||
setModels(modelsRes.data)
|
||||
setSettings(settingsRes.data)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to load data')
|
||||
} finally {
|
||||
|
|
@ -116,6 +127,46 @@ export default function AdminPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const searchEmbeddingModels = async () => {
|
||||
setEmbedSearchError('')
|
||||
setEmbedSearchResults([])
|
||||
setEmbedSearchLoading(true)
|
||||
try {
|
||||
const res = await api.get('/admin/litellm/models')
|
||||
const all = res.data.models || []
|
||||
// Filter to likely embedding models
|
||||
setEmbedSearchResults(all)
|
||||
} catch (err) {
|
||||
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
} finally {
|
||||
setEmbedSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testEmbedding = async () => {
|
||||
setEmbedTestResult(null)
|
||||
setEmbedTestLoading(true)
|
||||
try {
|
||||
const res = await api.post('/admin/embedding/test')
|
||||
setEmbedTestResult({ ok: true, ...res.data })
|
||||
} catch (err) {
|
||||
setEmbedTestResult({ ok: false, error: err.response?.data?.detail || 'Test failed' })
|
||||
} finally {
|
||||
setEmbedTestLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveEmbeddingModel = async (modelId) => {
|
||||
try {
|
||||
await api.put('/admin/settings', { embedding_model: modelId })
|
||||
setSettings(s => ({ ...s, embedding_model: modelId }))
|
||||
setEmbedSearchResults([])
|
||||
setSuccess(`Embedding model set to: ${modelId}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to save embedding model')
|
||||
}
|
||||
}
|
||||
|
||||
const addFromSearch = (modelId) => {
|
||||
setNewModel(m => ({ ...m, model_id: modelId, name: modelId }))
|
||||
setSearchResults([])
|
||||
|
|
@ -139,9 +190,9 @@ export default function AdminPage() {
|
|||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{['models', 'users'].map(t => (
|
||||
{['models', 'users', 'settings'].map(t => (
|
||||
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
|
||||
{t === 'models' ? 'AI Models' : 'Users'}
|
||||
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -237,11 +288,11 @@ export default function AdminPage() {
|
|||
) : (
|
||||
modelsByTask[task].map(m => (
|
||||
<div className="section-item" key={m.id}>
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong>{m.name}</strong>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace' }}>{m.model_id}</div>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||
{m.is_default && <span className="badge badge-ready">Default</span>}
|
||||
{!m.is_default && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
|
||||
|
|
@ -351,6 +402,144 @@ export default function AdminPage() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'settings' && (
|
||||
<div className="card">
|
||||
<h2>More Settings</h2>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
{/* Public Registration */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block', marginBottom: 4 }}>Public Registration</strong>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Allow new users to create accounts. Admins can always create users manually.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.registration_enabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setSettings(s => ({ ...s, registration_enabled: enabled }))
|
||||
try {
|
||||
await api.put('/admin/settings', { registration_enabled: enabled })
|
||||
setSuccess(`Registration ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update setting')
|
||||
setSettings(s => ({ ...s, registration_enabled: !enabled }))
|
||||
}
|
||||
}}
|
||||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
{settings.registration_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* AWS Polly */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.polly_enabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setSettings(s => ({ ...s, polly_enabled: enabled }))
|
||||
try {
|
||||
await api.put('/admin/settings', { polly_enabled: enabled })
|
||||
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update setting')
|
||||
setSettings(s => ({ ...s, polly_enabled: !enabled }))
|
||||
}
|
||||
}}
|
||||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||||
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Embedding Model */}
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 12 }}>
|
||||
Model used to generate semantic search vectors. Must output {1024} dimensions to match the database schema.
|
||||
Current: <code style={{ background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 4 }}>{settings.embedding_model || '(not set)'}</code>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.embedding_model}
|
||||
onChange={e => setSettings(s => ({ ...s, embedding_model: e.target.value }))}
|
||||
placeholder="e.g. ge-gemini-embedding-001"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => saveEmbeddingModel(settings.embedding_model)}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={searchEmbeddingModels} disabled={embedSearchLoading}>
|
||||
{embedSearchLoading ? 'Searching...' : 'Search LiteLLM'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}>
|
||||
{embedTestLoading ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
</div>
|
||||
{embedTestResult && (
|
||||
<div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}>
|
||||
{embedTestResult.ok
|
||||
? `OK — ${embedTestResult.model} · ${embedTestResult.dimensions} dims`
|
||||
: embedTestResult.error}
|
||||
</div>
|
||||
)}
|
||||
{embedSearchError && <div className="alert alert-error" style={{ marginBottom: 8 }}>{embedSearchError}</div>}
|
||||
{embedSearchResults.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{embedSearchResults.length} embedding models found — click to select</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={embedSearchFilter}
|
||||
onChange={e => setEmbedSearchFilter(e.target.value)}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 180 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
|
||||
{embedSearchResults
|
||||
.filter(m => !embedSearchFilter || m.toLowerCase().includes(embedSearchFilter.toLowerCase()))
|
||||
.map(modelId => (
|
||||
<div
|
||||
key={modelId}
|
||||
style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '8px 12px', borderBottom: '1px solid var(--border)',
|
||||
fontSize: '0.85rem', fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelId}</span>
|
||||
<button className="btn btn-primary btn-sm" style={{ flexShrink: 0 }} onClick={() => saveEmbeddingModel(modelId)}>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ function JobDetail({ job }) {
|
|||
{job.quiz_id && (
|
||||
<Link to={`/quizzes/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
<button className="btn btn-danger btn-sm" onClick={async () => {
|
||||
try {
|
||||
await api.post(`/quizzes/job/${job.job_id}/cancel`)
|
||||
job.status = 'cancelled'
|
||||
load()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.detail || 'Failed to cancel')
|
||||
}
|
||||
}}>Cancel</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setExpanded(v => !v)}>
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
|
|
|
|||
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 [filterCatId, setFilterCatId] = useState('')
|
||||
const [showUncategorized, setShowUncategorized] = useState(false)
|
||||
const [showFavorites, setShowFavorites] = useState(false)
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [studyQuestion, setStudyQuestion] = useState(null)
|
||||
const [editQuestion, setEditQuestion] = useState(null)
|
||||
|
|
@ -244,15 +246,17 @@ export default function QuestionBankPage() {
|
|||
|
||||
useEffect(() => {
|
||||
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
|
||||
api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode, size = pageSize) => {
|
||||
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
|
||||
if (query.trim()) params.q = query.trim()
|
||||
if (catId) params.category_id = parseInt(catId)
|
||||
if (uncatOnly) params.uncategorized = true
|
||||
if (favOnly) params.favorites_only = true
|
||||
const res = await api.get('/questions/bank', { params })
|
||||
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
|
||||
setTotal(res.data.total)
|
||||
|
|
@ -262,9 +266,9 @@ export default function QuestionBankPage() {
|
|||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode, pageSize), 300)
|
||||
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, showFavorites, searchMode, pageSize), 300)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, filterCatId, showUncategorized, searchMode, pageSize])
|
||||
}, [searchQuery, filterCatId, showUncategorized, showFavorites, searchMode, pageSize])
|
||||
|
||||
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||
|
||||
|
|
@ -275,6 +279,7 @@ export default function QuestionBankPage() {
|
|||
if (searchQuery.trim()) params.q = searchQuery.trim()
|
||||
if (filterCatId) params.category_id = parseInt(filterCatId)
|
||||
if (showUncategorized) params.uncategorized = true
|
||||
if (showFavorites) params.favorites_only = true
|
||||
const res = await api.get('/questions/bank/ids', { params })
|
||||
setSelectedIds(new Set(res.data))
|
||||
} catch {
|
||||
|
|
@ -326,7 +331,7 @@ export default function QuestionBankPage() {
|
|||
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
if (parseInt(filterCatId) === catId) setFilterCatId('')
|
||||
loadQuestions(searchQuery, 0, '', showUncategorized)
|
||||
loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +340,21 @@ export default function QuestionBankPage() {
|
|||
setMoveToCatId('')
|
||||
}
|
||||
|
||||
const toggleFavorite = async (questionId) => {
|
||||
const isFavorited = favorites.includes(questionId)
|
||||
try {
|
||||
if (isFavorited) {
|
||||
await api.delete(`/favorites/${questionId}`)
|
||||
setFavorites(prev => prev.filter(id => id !== questionId))
|
||||
} else {
|
||||
await api.post('/favorites', { question_id: questionId })
|
||||
setFavorites(prev => [...prev, questionId])
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.detail || 'Failed to update favorite')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Delete category dialog */}
|
||||
|
|
@ -398,26 +418,26 @@ export default function QuestionBankPage() {
|
|||
</div>
|
||||
|
||||
{/* Category filter chips */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
|
||||
<button className={`btn btn-sm ${!filterCatId && !showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All ({total})</button>
|
||||
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatId('') }}>Uncategorized</button>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false) }}>
|
||||
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
|
||||
</button>
|
||||
{isModerator && <button onClick={() => deleteCategory(cat.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
|
||||
<button className={`btn btn-sm ${!filterCatId && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilterCatId(''); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
|
||||
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowFavorites(v => !v); setFilterCatId(''); setShowUncategorized(false) }}>⭐ Favorites ({favorites.length})</button>
|
||||
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatId(''); setShowFavorites(false) }}>Uncategorized</button>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false); setShowFavorites(false) }}>
|
||||
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
|
||||
</button>
|
||||
{isModerator && <button onClick={() => deleteCategory(cat.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search row */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
|
|
@ -514,6 +534,20 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => toggleFavorite(q.id)}
|
||||
title={favorites.includes(q.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.3rem',
|
||||
padding: 4,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{favorites.includes(q.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
|
||||
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
|
||||
</div>
|
||||
|
|
@ -526,7 +560,7 @@ export default function QuestionBankPage() {
|
|||
|
||||
{questions.length < total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized)} disabled={loading}>
|
||||
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized, showFavorites)} disabled={loading}>
|
||||
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -155,6 +155,9 @@ export default function QuizEditPage() {
|
|||
const [questions, setQuestions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
const [savingTitle, setSavingTitle] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
@ -162,6 +165,7 @@ export default function QuizEditPage() {
|
|||
api.get(`/quizzes/${id}/questions`),
|
||||
]).then(([qRes, questRes]) => {
|
||||
setQuiz(qRes.data)
|
||||
setTitleValue(qRes.data.title)
|
||||
setQuestions(questRes.data)
|
||||
}).catch(() => navigate('/quizzes'))
|
||||
.finally(() => setLoading(false))
|
||||
|
|
@ -178,16 +182,65 @@ export default function QuizEditPage() {
|
|||
setQuestions(prev => prev.filter(q => q.id !== questionId))
|
||||
}
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!titleValue.trim()) { setError('Title cannot be empty'); return }
|
||||
setSavingTitle(true); setError('')
|
||||
try {
|
||||
const res = await api.patch(`/quizzes/${id}`, { title: titleValue.trim() })
|
||||
setQuiz(res.data)
|
||||
setTitleValue(res.data.title)
|
||||
setEditingTitle(false)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update title')
|
||||
} finally { setSavingTitle(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading…</div>
|
||||
if (!quiz) return null
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto' }}>
|
||||
<div className="card" style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ marginBottom: 4 }}>Edit Quiz</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{quiz.title} · {questions.length} questions</p>
|
||||
{!editingTitle ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', margin: 0 }}>
|
||||
{quiz.title} · {questions.length} questions
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setEditingTitle(true)}
|
||||
style={{ fontSize: '0.75rem', padding: '2px 8px' }}
|
||||
title="Edit quiz title"
|
||||
>
|
||||
✏️ Edit Title
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={titleValue}
|
||||
onChange={e => setTitleValue(e.target.value)}
|
||||
placeholder="Quiz title"
|
||||
style={{ width: '100%', padding: '6px 10px', marginBottom: 8, fontSize: '0.9rem' }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={saveTitle} disabled={savingTitle}>
|
||||
{savingTitle ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => { setEditingTitle(false); setTitleValue(quiz.title); setError('') }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/quizzes/${id}`} className="btn btn-secondary btn-sm">← Back to Quiz</Link>
|
||||
|
|
|
|||
|
|
@ -71,16 +71,9 @@ function TimerDisplay({ seconds, total }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
|
||||
function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
|
||||
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
|
||||
const [saved, setSaved] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/attempts/progress', { params: { quiz_id: quiz.id } })
|
||||
.then(res => { if (res.data) setSaved(res.data) })
|
||||
.catch(() => {})
|
||||
}, [quiz.id])
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '40px auto' }}>
|
||||
|
|
@ -91,19 +84,6 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
|
|||
{quiz.questions_count} questions
|
||||
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
|
||||
</p>
|
||||
|
||||
|
||||
{saved && saved.attempt_id && (
|
||||
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--option-sel-bd)', borderRadius: 8, padding: '12px 14px', marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>You have an in-progress attempt</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Q{(saved.current_idx ?? 0) + 1} · {Object.keys(saved.answers || {}).length} answered
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onResume(saved)}>Resume →</button>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
|
||||
{[
|
||||
|
|
@ -165,6 +145,8 @@ export default function QuizPage() {
|
|||
const [totalTime, setTotalTime] = useState(null)
|
||||
const [toast, setToast] = useState('')
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const [startedAt, setStartedAt] = useState(null)
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const timerRef = useRef(null)
|
||||
const toastRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
|
@ -177,25 +159,73 @@ export default function QuizPage() {
|
|||
|
||||
const [leaveTarget, setLeaveTarget] = useState(null)
|
||||
|
||||
const resumeQuiz = useCallback(async (saved) => {
|
||||
const mode = saved.mode || saved.quizMode
|
||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
const savedAnswers = saved.answers || {}
|
||||
|
||||
// For study mode, load quiz with correct answers BEFORE showing
|
||||
if (mode === 'study') {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}?study=true`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch { }
|
||||
} else {
|
||||
// For exam mode, ensure quiz data is loaded
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
hasStarted.current = true
|
||||
setQuizMode(mode)
|
||||
setAnswers(savedAnswers)
|
||||
setCurrentIdx(savedIdx)
|
||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
||||
if (saved.voice) setSelectedVoice(saved.voice)
|
||||
if (saved.started_at) setStartedAt(saved.started_at)
|
||||
// Restore timer — calculate remaining from started_at + total_time
|
||||
if (saved.total_time && saved.started_at) {
|
||||
const elapsed = Math.floor((new Date() - new Date(saved.started_at)) / 1000)
|
||||
const remaining = Math.max(0, saved.total_time - elapsed)
|
||||
setTimeLeft(remaining)
|
||||
setTotalTime(saved.total_time)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// Warn before tab/window close when mid-quiz
|
||||
useEffect(() => {
|
||||
if (!attemptId) return
|
||||
const handler = (e) => { e.preventDefault(); e.returnValue = 'You have an in-progress quiz. Your progress is saved.' }
|
||||
const msg = timeLeft !== null
|
||||
? 'Your quiz is timed. If you close this tab, the timer will continue and auto-submit when time expires. Progress is saved.'
|
||||
: 'You have an in-progress quiz. Your progress is saved.'
|
||||
const handler = (e) => { e.preventDefault(); e.returnValue = msg }
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [attemptId])
|
||||
}, [attemptId, timeLeft])
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [quizRes, voicesRes] = await Promise.all([
|
||||
const [quizRes, voicesRes, favoritesRes] = await Promise.all([
|
||||
api.get(`/quizzes/${id}`),
|
||||
api.get('/tts/voices').catch(() => ({ data: [] })),
|
||||
api.get('/favorites').catch(() => ({ data: [] })),
|
||||
])
|
||||
setQuiz(quizRes.data)
|
||||
setVoices(voicesRes.data)
|
||||
setFavorites(favoritesRes.data)
|
||||
const def = voicesRes.data.find(v => v.is_default)
|
||||
if (def) setSelectedVoice(def.id)
|
||||
|
||||
// Check for saved progress and auto-resume
|
||||
try {
|
||||
const progressRes = await api.get('/attempts/progress', { params: { quiz_id: id } })
|
||||
if (progressRes.data) {
|
||||
await resumeQuiz(progressRes.data)
|
||||
}
|
||||
} catch {}
|
||||
} catch { navigate('/') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
|
@ -216,6 +246,8 @@ export default function QuizPage() {
|
|||
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
|
||||
setAttemptId(attemptRes.data.id)
|
||||
const mins = timerMinutes || quizRes.data.time_limit_minutes
|
||||
const now = new Date().toISOString()
|
||||
setStartedAt(now)
|
||||
if (mode === 'exam' && mins) {
|
||||
const secs = mins * 60
|
||||
setTimeLeft(secs); setTotalTime(secs)
|
||||
|
|
@ -248,10 +280,12 @@ useEffect(() => {
|
|||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
}).catch(() => {})
|
||||
}, 1500) // debounce 1.5s
|
||||
return () => clearTimeout(saveProgressRef.current)
|
||||
}, [answers, currentIdx, attemptId, timeLeft])
|
||||
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
|
||||
|
||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
|
||||
|
|
@ -264,7 +298,7 @@ useEffect(() => {
|
|||
await new Promise(r => setTimeout(r, 1200))
|
||||
}
|
||||
clearInterval(timerRef.current)
|
||||
api.delete(`/attempts/progress/${id}`).catch(() => {})
|
||||
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const submission = {
|
||||
|
|
@ -281,37 +315,6 @@ useEffect(() => {
|
|||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
|
||||
if (!quiz) return null
|
||||
const resumeQuiz = async (saved) => {
|
||||
const mode = saved.mode || saved.quizMode
|
||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
const savedAnswers = saved.answers || {}
|
||||
|
||||
// For study mode, load quiz with correct answers BEFORE showing
|
||||
if (mode === 'study') {
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}?study=true`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch { }
|
||||
} else {
|
||||
// For exam mode, ensure quiz data is loaded
|
||||
try {
|
||||
const quizRes = await api.get(`/quizzes/${id}`)
|
||||
setQuiz(quizRes.data)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
hasStarted.current = true
|
||||
setQuizMode(mode)
|
||||
setAnswers(savedAnswers)
|
||||
setCurrentIdx(savedIdx)
|
||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
||||
if (saved.voice) setSelectedVoice(saved.voice)
|
||||
// Restore timer (paused time — restarts from where it was)
|
||||
if (saved.time_left != null && saved.time_left > 0) {
|
||||
setTimeLeft(saved.time_left)
|
||||
setTotalTime(saved.time_left) // use remaining as new total for progress bar
|
||||
}
|
||||
}
|
||||
|
||||
if (!quizMode) return (
|
||||
<div>
|
||||
|
|
@ -320,7 +323,7 @@ useEffect(() => {
|
|||
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
</div>
|
||||
)}
|
||||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onResume={resumeQuiz} />
|
||||
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -331,6 +334,23 @@ useEffect(() => {
|
|||
const totalCount = questions.length
|
||||
const isLast = currentIdx === totalCount - 1
|
||||
|
||||
const toggleFavorite = async (questionId) => {
|
||||
const isFavorited = favorites.includes(questionId)
|
||||
try {
|
||||
if (isFavorited) {
|
||||
await api.delete(`/favorites/${questionId}`)
|
||||
setFavorites(prev => prev.filter(id => id !== questionId))
|
||||
showToast('Removed from favorites')
|
||||
} else {
|
||||
await api.post('/favorites', { question_id: questionId })
|
||||
setFavorites(prev => [...prev, questionId])
|
||||
showToast('Added to favorites')
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.response?.data?.detail || 'Failed to update favorite')
|
||||
}
|
||||
}
|
||||
|
||||
const QuestionDot = ({ q, i }) => {
|
||||
const isActive = i === currentIdx
|
||||
const isDone = !!answers[q.id]
|
||||
|
|
@ -350,15 +370,21 @@ useEffect(() => {
|
|||
{/* In-app leave confirmation */}
|
||||
{leaveTarget && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 380, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>⚠️</div>
|
||||
<h2 style={{ marginBottom: 8 }}>Leave quiz?</h2>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 14, padding: 28, maxWidth: 420, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>⏸</div>
|
||||
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
|
||||
Your progress is saved — resume from where you left off next time.
|
||||
Your progress is saved — resume from where you left off.
|
||||
{timeLeft !== null && (
|
||||
<><br/><br/>
|
||||
<strong style={{ color: '#f59e0b' }}>⚠️ Timer continues:</strong> This quiz is timed.
|
||||
If you leave, the timer keeps counting and will auto-submit when it expires.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
|
||||
<button className="btn btn-danger" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Leave</button>
|
||||
<button className="btn btn-primary" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Suspend & Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -391,8 +417,11 @@ useEffect(() => {
|
|||
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget('/')} title="Save progress and exit">
|
||||
⏸ Suspend
|
||||
</button>
|
||||
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -418,7 +447,26 @@ useEffect(() => {
|
|||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{current && (
|
||||
<div className="question-card">
|
||||
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
<button
|
||||
onClick={() => toggleFavorite(current.id)}
|
||||
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.4rem',
|
||||
padding: 4,
|
||||
lineHeight: 1,
|
||||
transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.15)'}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
|
||||
>
|
||||
{favorites.includes(current.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
{voices.length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<TTSButton
|
||||
|
|
|
|||
|
|
@ -138,6 +138,10 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
|
|||
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}>
|
||||
{isModerator && (
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
|
||||
<Link to={`/quizzes/${quiz.id}/edit`} title="Edit quiz"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4, textDecoration: 'none', display: 'flex', alignItems: 'center' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✏️</Link>
|
||||
<button title={quiz.is_published === 0 ? 'Hidden from users — click to publish' : 'Visible — click to hide'} onClick={togglePublish}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: quiz.is_published === 0 ? '#ef4444' : '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
|
|
|
|||
Loading…
Reference in a new issue