Major: categories, question bank, security fixes, mobile layout, UX improvements
Security: - Nginx: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, Referrer-Policy, Permissions-Policy headers - Redis-backed login rate limiting (survives container restarts) - Admin litellm/models endpoint: api_key moved from GET query param to POST body - Nextcloud credentials moved from localStorage to sessionStorage (cleared on tab close) UX / Layout: - Login: unverified users see inline "Resend verification email" option - QuizPage mobile: TTS Listen button on its own row below question text - QuizPage mobile: Voice selector on its own row in header card (not squashed with timer) - QuizEditPage: scroll position preserved after saving a question edit Quiz Categories: - New QuizCategory model + quiz_categories table - category_id column added to quizzes table - GET/POST/DELETE /api/categories endpoints - Quizzes grouped by category in QuizzesPage; moderators can assign via 🏷 menu - Uncategorized section shown when categories exist Question Bank: - GET /api/questions/bank — search all questions across quizzes - POST /api/questions/from-bank — create new quiz from selected questions (copies, originals untouched) - QuestionBankPage: search, checkbox select, study modal, create quiz form - "Question Bank" link added to Navbar Search: - "View all N questions →" button expands to full question list - Each question has a Study button opening in-place modal with study mode - Summary view shows 2 questions per quiz with Study button Extraction prompt: - Stronger emphasis on correct_answer field with step-by-step letter → full text example - Explicit instruction never to store just the letter Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7817f89849
commit
9af63e67b5
19 changed files with 926 additions and 216 deletions
|
|
@ -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
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -119,19 +119,30 @@ def seed_default_models():
|
|||
|
||||
|
||||
def setup_pgvector():
|
||||
"""Enable pgvector extension and add embedding column if missing."""
|
||||
"""Enable pgvector, add new columns/tables, run schema migrations."""
|
||||
from sqlalchemy import text
|
||||
# Import new model so create_all picks it up
|
||||
from app.models import quiz_category # 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)
|
||||
"""))
|
||||
# HNSW index for fast cosine similarity — created only if not exists
|
||||
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
|
||||
ON questions USING hnsw (embedding vector_cosine_ops)
|
||||
"""))
|
||||
# Quiz categories
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS quiz_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quizzes
|
||||
ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES quiz_categories(id) ON DELETE SET NULL
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -210,6 +221,8 @@ app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
|
|||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(tts.router, prefix="/api/tts", tags=["tts"])
|
||||
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.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
|||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
|
||||
|
||||
|
||||
class Quiz(Base):
|
||||
|
|
@ -12,6 +13,7 @@ class Quiz(Base):
|
|||
id = Column(Integer, primary_key=True, index=True)
|
||||
section_id = Column(Integer, ForeignKey("sections.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("quiz_categories.id", ondelete="SET NULL"), nullable=True)
|
||||
title = Column(String, nullable=False)
|
||||
questions_count = Column(Integer, default=0)
|
||||
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
|
||||
|
|
@ -21,6 +23,7 @@ class Quiz(Base):
|
|||
|
||||
section = relationship("Section", back_populates="quizzes")
|
||||
user = relationship("User", back_populates="quizzes")
|
||||
category = relationship("QuizCategory", back_populates="quizzes", foreign_keys=[category_id])
|
||||
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan")
|
||||
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
|
||||
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")
|
||||
|
|
|
|||
15
backend/app/models/quiz_category.py
Normal file
15
backend/app/models/quiz_category.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class QuizCategory(Base):
|
||||
__tablename__ = "quiz_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
quizzes = relationship("Quiz", back_populates="category", foreign_keys="Quiz.category_id")
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import httpx
|
||||
|
||||
|
|
@ -97,15 +98,19 @@ def list_available_models(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/litellm/models")
|
||||
class LiteLLMModelQuery(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
@router.post("/litellm/models")
|
||||
def search_litellm_models(
|
||||
api_key: str = Query(None),
|
||||
api_base: str = Query(None),
|
||||
body: LiteLLMModelQuery = LiteLLMModelQuery(),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
|
||||
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = body.api_key or settings.LITELLM_API_KEY
|
||||
|
||||
if base:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||
|
|
@ -20,17 +19,22 @@ from app.utils.auth import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple in-memory login rate limiter: max 10 attempts per IP per 15 min
|
||||
_login_attempts: dict = defaultdict(list)
|
||||
|
||||
def _check_login_rate_limit(client_ip: str):
|
||||
now = datetime.utcnow()
|
||||
window = now - timedelta(minutes=15)
|
||||
attempts = [t for t in _login_attempts[client_ip] if t > window]
|
||||
_login_attempts[client_ip] = attempts
|
||||
if len(attempts) >= 10:
|
||||
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
||||
_login_attempts[client_ip].append(now)
|
||||
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
||||
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)
|
||||
key = f"login_attempts:{client_ip}"
|
||||
count = r.incr(key)
|
||||
if count == 1:
|
||||
r.expire(key, 15 * 60)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Redis unavailable — degrade gracefully, don't block login
|
||||
|
||||
|
||||
# Rate limit: max 3 reset requests per email per hour
|
||||
|
|
|
|||
89
backend/app/routers/categories.py
Normal file
89
backend/app/routers/categories.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.quiz_category import QuizCategory
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
quiz_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CategoryResponse])
|
||||
def list_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
cats = db.query(QuizCategory).order_by(QuizCategory.name).all()
|
||||
return [
|
||||
CategoryResponse(id=c.id, name=c.name,
|
||||
quiz_count=db.query(Quiz).filter(Quiz.category_id == c.id).count())
|
||||
for c in cats
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse)
|
||||
def create_category(
|
||||
data: CategoryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
if not data.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Category name cannot be empty")
|
||||
existing = db.query(QuizCategory).filter(QuizCategory.name == data.name.strip()).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Category already exists")
|
||||
cat = QuizCategory(name=data.name.strip(), user_id=current_user.id)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return CategoryResponse(id=cat.id, name=cat.name, quiz_count=0)
|
||||
|
||||
|
||||
@router.delete("/{category_id}", status_code=204)
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
cat = db.query(QuizCategory).filter(QuizCategory.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
# Unassign quizzes
|
||||
db.query(Quiz).filter(Quiz.category_id == category_id).update({"category_id": None})
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.patch("/quizzes/{quiz_id}")
|
||||
def set_quiz_category(
|
||||
quiz_id: int,
|
||||
category_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Assign or remove a category from a quiz. Pass category_id=null to uncategorize."""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
if not quiz:
|
||||
raise HTTPException(status_code=404, detail="Quiz not found")
|
||||
if category_id is not None:
|
||||
cat = db.query(QuizCategory).filter(QuizCategory.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
quiz.category_id = category_id
|
||||
db.commit()
|
||||
return {"quiz_id": quiz_id, "category_id": category_id}
|
||||
128
backend/app/routers/questions.py
Normal file
128
backend/app/routers/questions.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Question bank — view, search, and create quizzes from individual questions."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import cast, String, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.question import Question
|
||||
from app.models.quiz import Quiz
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user, require_moderator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/bank")
|
||||
def get_question_bank(
|
||||
q: str | None = Query(None),
|
||||
quiz_id: int | None = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
|
||||
query = db.query(Question)
|
||||
|
||||
if quiz_id:
|
||||
query = query.filter(Question.quiz_id == quiz_id)
|
||||
|
||||
if q and q.strip():
|
||||
phrase = q.strip()
|
||||
query = query.filter(
|
||||
or_(
|
||||
Question.question_text.ilike(f"%{phrase}%"),
|
||||
cast(Question.options, String).ilike(f"%{phrase}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all()
|
||||
|
||||
# Attach quiz title
|
||||
quiz_cache: dict[int, str] = {}
|
||||
result = []
|
||||
for qu in questions:
|
||||
if qu.quiz_id not in quiz_cache:
|
||||
quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first()
|
||||
quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}"
|
||||
result.append({
|
||||
"id": qu.id,
|
||||
"quiz_id": qu.quiz_id,
|
||||
"quiz_title": quiz_cache[qu.quiz_id],
|
||||
"question_text": qu.question_text,
|
||||
"question_type": qu.question_type,
|
||||
"options": qu.options,
|
||||
"correct_answer": qu.correct_answer,
|
||||
"explanation": qu.explanation,
|
||||
})
|
||||
|
||||
return {"total": total, "questions": result}
|
||||
|
||||
|
||||
class CreateFromBankRequest(BaseModel):
|
||||
title: str
|
||||
question_ids: list[int]
|
||||
mode: str = "timed"
|
||||
time_limit_minutes: int | None = None
|
||||
|
||||
|
||||
@router.post("/from-bank")
|
||||
def create_quiz_from_bank(
|
||||
data: CreateFromBankRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator),
|
||||
):
|
||||
"""Create a new quiz by copying selected questions from the question bank."""
|
||||
if not data.title.strip():
|
||||
raise HTTPException(status_code=400, detail="Title is required")
|
||||
if not data.question_ids:
|
||||
raise HTTPException(status_code=400, detail="Select at least one question")
|
||||
if data.mode not in ("timed", "learning"):
|
||||
raise HTTPException(status_code=400, detail="Mode must be timed or learning")
|
||||
|
||||
# Fetch source questions
|
||||
source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
|
||||
if not source_questions:
|
||||
raise HTTPException(status_code=404, detail="No valid questions found")
|
||||
|
||||
# Use the section_id from the first source question's quiz
|
||||
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first()
|
||||
if not first_quiz:
|
||||
raise HTTPException(status_code=400, detail="Source quiz not found")
|
||||
|
||||
import json
|
||||
new_quiz = Quiz(
|
||||
section_id=first_quiz.section_id,
|
||||
user_id=current_user.id,
|
||||
title=data.title.strip(),
|
||||
questions_count=len(source_questions),
|
||||
mode=data.mode,
|
||||
time_limit_minutes=data.time_limit_minutes,
|
||||
)
|
||||
db.add(new_quiz)
|
||||
db.flush()
|
||||
|
||||
# Copy questions (independent copies — original questions untouched)
|
||||
from app.services import embedding_service
|
||||
for sq in source_questions:
|
||||
new_q = Question(
|
||||
quiz_id=new_quiz.id,
|
||||
question_text=sq.question_text,
|
||||
question_type=sq.question_type,
|
||||
options=sq.options,
|
||||
correct_answer=sq.correct_answer,
|
||||
explanation=sq.explanation,
|
||||
page_reference=sq.page_reference,
|
||||
)
|
||||
db.add(new_q)
|
||||
db.flush()
|
||||
try:
|
||||
embedding_service.embed_question(new_q)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
db.refresh(new_quiz)
|
||||
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}
|
||||
|
|
@ -37,6 +37,7 @@ class QuizResponse(BaseModel):
|
|||
mode: str
|
||||
time_limit_minutes: int | None
|
||||
skipped_questions: str | None = None
|
||||
category_id: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -24,26 +24,28 @@ These PDFs follow a strict format:
|
|||
5. A "Critique:" section with detailed reasoning
|
||||
6. A "Content Specifications:" section listing the learning objectives
|
||||
|
||||
Your task: extract every question from the content below and return ONLY a JSON object in this exact format:
|
||||
Your task: extract every question and return ONLY a JSON object in this exact format:
|
||||
|
||||
{{"questions": [
|
||||
{{
|
||||
"question_text": "<full question stem including any patient vignette>",
|
||||
"question_type": "mcq",
|
||||
"options": ["<option A text>", "<option B text>", "<option C text>", "<option D text>", "<option E text>"],
|
||||
"correct_answer": "<full text of the correct option, not just the letter>",
|
||||
"correct_answer": "<full text of the correct option, NOT the letter>",
|
||||
"explanation": "<explanation paragraph>\\n\\nCritique: <critique section verbatim>\\n\\nContent Specifications: <content spec section verbatim>",
|
||||
"page_reference": {page_ref}
|
||||
}}
|
||||
]}}
|
||||
|
||||
Rules:
|
||||
- "Correct Answer: C" means option C is correct — store the full text of option C as correct_answer
|
||||
- If no "Correct Answer:" line appears after a question, set correct_answer to null
|
||||
- Preserve ALL text exactly as written — do not summarize or paraphrase
|
||||
- Include the Critique and Content Specifications verbatim — they are critical
|
||||
- Extract ALL questions in the content, even if partially cut off
|
||||
- Return ONLY the JSON — no markdown, no explanation, no preamble
|
||||
CRITICAL RULES — the correct_answer field is the most important:
|
||||
- Find the line "Correct Answer: X" after each question. X is the letter (A, B, C, D, or E).
|
||||
- Look up the full text of option X from the options list and store it as correct_answer.
|
||||
- Example: if options are ["alpha","beta","gamma","delta","epsilon"] and Correct Answer is C, then correct_answer = "gamma"
|
||||
- NEVER store just a letter like "C" — always store the FULL OPTION TEXT.
|
||||
- If you cannot find "Correct Answer:" for a question, set correct_answer to null (do not guess).
|
||||
- Do not omit any question — extract ALL questions even if partially cut off.
|
||||
- Preserve ALL text verbatim — do not summarize or paraphrase anything.
|
||||
- Return ONLY the JSON — no markdown, no preamble, no explanation.
|
||||
|
||||
Content from page(s) {page_info}:
|
||||
{content}"""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; font-src 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import ResultsPage from './pages/ResultsPage'
|
|||
import AdminPage from './pages/AdminPage'
|
||||
import AccountPage from './pages/AccountPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import QuestionBankPage from './pages/QuestionBankPage'
|
||||
import QuizEditPage from './pages/QuizEditPage'
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
|
|
@ -60,6 +61,7 @@ function AppRoutes() {
|
|||
<Route path="/quizzes/:id/edit" element={<ProtectedRoute requireModerator><QuizEditPage /></ProtectedRoute>} />
|
||||
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default function Navbar() {
|
|||
<nav>
|
||||
<Link to="/">Dashboard</Link>
|
||||
<Link to="/quizzes">Quizzes</Link>
|
||||
<Link to="/question-bank">Question Bank</Link>
|
||||
{isModerator && <Link to="/upload">Upload PDF</Link>}
|
||||
<Link to="/settings" title="Settings">⚙ Settings</Link>
|
||||
<button onClick={logout}>Logout</button>
|
||||
|
|
|
|||
|
|
@ -1,34 +1,74 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unverified, setUnverified] = useState(false)
|
||||
const [resendSent, setResendSent] = useState(false)
|
||||
const [resending, setResending] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setUnverified(false)
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Login failed')
|
||||
if (err.response?.status === 403) {
|
||||
setUnverified(true)
|
||||
} else {
|
||||
setError(err.response?.data?.detail || 'Login failed')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resendVerification = async () => {
|
||||
setResending(true)
|
||||
try {
|
||||
await api.post('/auth/resend-verification', { email })
|
||||
setResendSent(true)
|
||||
} catch {
|
||||
setResendSent(true) // always show success to avoid enumeration
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Sign In</h1>
|
||||
|
||||
{unverified && !resendSent && (
|
||||
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: '14px 16px', marginBottom: 16 }}>
|
||||
<p style={{ margin: '0 0 10px', fontSize: '0.875rem', color: '#92400e', fontWeight: 500 }}>
|
||||
Your email is not verified. Please check your inbox.
|
||||
</p>
|
||||
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
|
||||
{resending ? 'Sending…' : 'Resend verification email'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resendSent && (
|
||||
<div className="alert alert-success">
|
||||
Verification email sent — check your inbox and spam folder.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
|
|
|
|||
251
frontend/src/pages/QuestionBankPage.jsx
Normal file
251
frontend/src/pages/QuestionBankPage.jsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
function QuestionStudyModal({ question, onClose }) {
|
||||
const [answered, setAnswered] = useState(null)
|
||||
|
||||
const isCorrect = answered === question.correct_answer
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px',
|
||||
}} onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={{
|
||||
background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: '24px',
|
||||
maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
|
||||
From: {question.quiz_title}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem', lineHeight: 1 }}>✕</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontWeight: 600, fontSize: '1rem', lineHeight: 1.6, marginBottom: 16, color: 'var(--text)' }}>
|
||||
{question.question_text}
|
||||
</p>
|
||||
|
||||
{question.options && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{question.options.map((opt, i) => {
|
||||
const isSelected = answered === opt
|
||||
const hasAnswered = !!answered
|
||||
const isCorrectOpt = opt === question.correct_answer
|
||||
const showCorrect = hasAnswered && isCorrectOpt
|
||||
const showWrong = hasAnswered && isSelected && !isCorrectOpt
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswered(opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||
<span className="option-letter">{letter}</span>
|
||||
<span style={{ flex: 1 }}>{opt}</span>
|
||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answered && question.explanation && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong>
|
||||
<div style={{ marginTop: 8 }}>{question.explanation}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answered && (
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuestionBankPage() {
|
||||
const [questions, setQuestions] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [createForm, setCreateForm] = useState({ title: '', mode: 'timed', time_limit_minutes: '' })
|
||||
const [createError, setCreateError] = useState('')
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const debounceRef = useRef(null)
|
||||
const LIMIT = 50
|
||||
|
||||
const load = async (query = searchQuery, off = offset) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = { limit: LIMIT, offset: off }
|
||||
if (query.trim()) params.q = query.trim()
|
||||
const res = await api.get('/questions/bank', { params })
|
||||
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
|
||||
setTotal(res.data.total)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => { setOffset(0); load(searchQuery, 0) }, 300)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery])
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const createQuiz = async () => {
|
||||
setCreateError('')
|
||||
if (!createForm.title.trim()) return setCreateError('Title is required')
|
||||
try {
|
||||
const res = await api.post('/questions/from-bank', {
|
||||
title: createForm.title,
|
||||
question_ids: [...selectedIds],
|
||||
mode: createForm.mode,
|
||||
time_limit_minutes: createForm.time_limit_minutes ? parseInt(createForm.time_limit_minutes) : null,
|
||||
})
|
||||
navigate(`/quizzes/${res.data.id}`)
|
||||
} catch (err) {
|
||||
setCreateError(err.response?.data?.detail || 'Failed to create quiz')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selected && <QuestionStudyModal question={selected} onClose={() => setSelected(null)} />}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 4 }}>Question Bank</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{total} questions across all quizzes</p>
|
||||
</div>
|
||||
{isModerator && selectedIds.size > 0 && (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
Create Quiz from {selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span>
|
||||
<input
|
||||
type="text" placeholder="Search questions..."
|
||||
value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }}
|
||||
/>
|
||||
</div>
|
||||
{isModerator && selectedIds.size > 0 && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedIds(new Set())}>Clear selection</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create quiz modal */}
|
||||
{creating && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 400, width: '100%' }}>
|
||||
<h2 style={{ marginBottom: 16 }}>Create Quiz from Bank</h2>
|
||||
{createError && <div className="alert alert-error">{createError}</div>}
|
||||
<div className="form-group">
|
||||
<label>Quiz Title</label>
|
||||
<input type="text" value={createForm.title} onChange={e => setCreateForm(f => ({ ...f, title: e.target.value }))} placeholder="Enter quiz title..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Mode</label>
|
||||
<select value={createForm.mode} onChange={e => setCreateForm(f => ({ ...f, mode: e.target.value }))}>
|
||||
<option value="timed">Timed (Exam)</option>
|
||||
<option value="learning">Learning (Study)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Time Limit (minutes, optional)</label>
|
||||
<input type="number" value={createForm.time_limit_minutes} onChange={e => setCreateForm(f => ({ ...f, time_limit_minutes: e.target.value }))} placeholder="Leave blank for no limit" min={1} />
|
||||
</div>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: 16 }}>
|
||||
{selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''} selected. Original questions remain unchanged.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary" onClick={createQuiz}>Create Quiz</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setCreating(false); setCreateError('') }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && questions.length === 0 && <div className="loading"><div className="spinner" /></div>}
|
||||
|
||||
{questions.map(q => (
|
||||
<div key={q.id} style={{
|
||||
background: 'var(--card-bg)', border: `1.5px solid ${selectedIds.has(q.id) ? 'var(--primary)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--card-radius)', padding: '16px 18px', marginBottom: 10,
|
||||
display: 'flex', gap: 12, alignItems: 'flex-start',
|
||||
}}>
|
||||
{isModerator && (
|
||||
<input type="checkbox" checked={selectedIds.has(q.id)} onChange={() => toggleSelect(q.id)}
|
||||
style={{ marginTop: 4, accentColor: 'var(--primary)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: 4 }}>
|
||||
From: {q.quiz_title}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--text)', lineHeight: 1.55, marginBottom: q.options ? 8 : 0 }}>
|
||||
{q.question_text.slice(0, 200)}{q.question_text.length > 200 ? '…' : ''}
|
||||
</p>
|
||||
{q.options && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{q.options.map((opt, i) => (
|
||||
<span key={i} style={{
|
||||
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 4,
|
||||
background: opt === q.correct_answer ? 'var(--correct-bg)' : 'var(--bg)',
|
||||
color: opt === q.correct_answer ? 'var(--correct-fg)' : 'var(--text-muted)',
|
||||
border: `1px solid ${opt === q.correct_answer ? 'var(--correct-bd)' : 'var(--border)'}`,
|
||||
}}>
|
||||
{String.fromCharCode(65 + i)}. {opt.slice(0, 40)}{opt.length > 40 ? '…' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setSelected(q)} style={{ flexShrink: 0 }}>
|
||||
Study
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{questions.length < total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button className="btn btn-secondary" onClick={() => { const next = offset + LIMIT; setOffset(next); load(searchQuery, next) }} disabled={loading}>
|
||||
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && questions.length === 0 && (
|
||||
<div className="card"><div className="empty-state">No questions found.</div></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -168,7 +168,10 @@ export default function QuizEditPage() {
|
|||
}, [id])
|
||||
|
||||
const handleSaved = (updated) => {
|
||||
const scrollY = window.scrollY
|
||||
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))
|
||||
// Restore scroll position after React re-renders
|
||||
requestAnimationFrame(() => window.scrollTo({ top: scrollY, behavior: 'instant' }))
|
||||
}
|
||||
|
||||
const handleDeleted = (questionId) => {
|
||||
|
|
|
|||
|
|
@ -251,30 +251,33 @@ useEffect(() => {
|
|||
|
||||
return (
|
||||
<div className="quiz-bottom">
|
||||
<div className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<span style={{
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
|
||||
}}>
|
||||
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
|
||||
</span>
|
||||
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 2 }}>{quiz.title}</h2>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
<span style={{
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||
padding: '2px 8px', borderRadius: 12, fontWeight: 600, marginRight: 8,
|
||||
}}>
|
||||
{isStudy ? '📖 Study Mode' : '🎯 Exam Mode'}
|
||||
</span>
|
||||
Question {currentIdx + 1} of {totalCount} · {answeredCount} answered
|
||||
</div>
|
||||
</div>
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{voices.length > 1 && (
|
||||
{voices.length > 1 && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<label style={{ fontSize: '0.78rem', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>🔊 Voice:</label>
|
||||
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
|
||||
disabled={ttsActive}
|
||||
title={ttsActive ? 'Stop the current audio before changing voice' : ''}
|
||||
title={ttsActive ? 'Stop audio before changing voice' : ''}
|
||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.8rem', opacity: ttsActive ? 0.5 : 1 }}>
|
||||
{voices.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar" style={{ marginBottom: 20 }}>
|
||||
|
|
@ -283,15 +286,17 @@ useEffect(() => {
|
|||
|
||||
{current && (
|
||||
<div className="question-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<h3 style={{ flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
<TTSButton
|
||||
key={`${current.id}_${answers[current.id] || ''}`}
|
||||
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||
voice={selectedVoice}
|
||||
onActiveChange={setTtsActive}
|
||||
/>
|
||||
</div>
|
||||
<h3 style={{ marginBottom: 8 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||
{voices.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<TTSButton
|
||||
key={`${current.id}_${answers[current.id] || ''}`}
|
||||
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||
voice={selectedVoice}
|
||||
onActiveChange={setTtsActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '8px 0', display: 'inline-block' }}>
|
||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
|
||||
|
|
@ -10,48 +10,158 @@ function HighlightText({ text, query }) {
|
|||
return (
|
||||
<span>
|
||||
{text.slice(0, idx)}
|
||||
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
<mark style={{ background: '#fef08a', padding: '0 1px', borderRadius: 2 }}>{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionStudyModal({ question, query, onClose }) {
|
||||
const [answered, setAnswered] = useState(null)
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Study Question</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>
|
||||
<HighlightText text={question.question_text} query={query} />
|
||||
</p>
|
||||
{question.options && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{question.options.map((opt, i) => {
|
||||
const isSelected = answered === opt
|
||||
const hasAnswered = !!answered
|
||||
const isCorrectOpt = opt === question.correct_answer
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswered(opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
|
||||
<span style={{ flex: 1 }}><HighlightText text={opt} query={query} /></span>
|
||||
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ {isSelected ? 'Your answer' : 'Correct'}</span>}
|
||||
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{answered && question.explanation && (
|
||||
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
|
||||
)}
|
||||
{answered && (
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange }) {
|
||||
const navigate = useNavigate()
|
||||
const [showCatMenu, setShowCatMenu] = useState(false)
|
||||
|
||||
const assignCategory = async (catId) => {
|
||||
try {
|
||||
await api.patch(`/categories/quizzes/${quiz.id}`, null, { params: { category_id: catId ?? '' } })
|
||||
onCategoryChange(quiz.id, catId)
|
||||
} catch { }
|
||||
setShowCatMenu(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={() => navigate(`/quizzes/${quiz.id}`)}
|
||||
style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 'var(--card-radius)', padding: '20px 18px', cursor: 'pointer', transition: 'box-shadow 0.15s, transform 0.15s', display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', boxShadow: 'var(--card-shadow)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}>
|
||||
{isModerator && (
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button title="Move to category" onClick={() => setShowCatMenu(v => !v)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>🏷</button>
|
||||
{showCatMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 4, zIndex: 100, minWidth: 160, boxShadow: '0 4px 16px rgba(0,0,0,0.15)' }}>
|
||||
<div onClick={() => assignCategory(null)} style={{ padding: '7px 12px', fontSize: '0.82rem', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = ''}>Uncategorized</div>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} onClick={() => assignCategory(cat.id)} style={{ padding: '7px 12px', fontSize: '0.82rem', cursor: 'pointer', fontWeight: quiz.category_id === cat.id ? 700 : 400, color: 'var(--text)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = ''}>{quiz.category_id === cat.id ? '✓ ' : ''}{cat.name}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={e => onDelete(e, quiz.id)} title="Delete quiz"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 6px', borderRadius: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
|
||||
{quiz.mode === 'learning' ? '📖' : '📝'}
|
||||
</div>
|
||||
<div style={{ paddingRight: isModerator ? 48 : 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', lineHeight: 1.3 }}>{quiz.title}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>{quiz.questions_count} question{quiz.questions_count !== 1 ? 's' : ''}</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: 20, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', color: quiz.mode === 'learning' ? '#065f46' : '#3730a3' }}>
|
||||
{quiz.mode === 'learning' ? 'Learning' : 'Timed'}
|
||||
</span>
|
||||
{quiz.time_limit_minutes && <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{quiz.time_limit_minutes} min</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const [quizzes, setQuizzes] = useState([])
|
||||
const [categories, setCategories] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchMode, setSearchMode] = useState('all')
|
||||
const [searchResults, setSearchResults] = useState(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [expandedSearch, setExpandedSearch] = useState(false)
|
||||
const [studyQuestion, setStudyQuestion] = useState(null)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [addingCat, setAddingCat] = useState(false)
|
||||
const debounceRef = useRef(null)
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/').then(res => setQuizzes(res.data))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
Promise.all([
|
||||
api.get('/quizzes/'),
|
||||
api.get('/categories/'),
|
||||
]).then(([qRes, cRes]) => {
|
||||
setQuizzes(qRes.data)
|
||||
setCategories(cRes.data)
|
||||
}).catch(console.error).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current)
|
||||
if (searchQuery.trim().length < 2) {
|
||||
setSearchResults(null)
|
||||
return
|
||||
}
|
||||
if (searchQuery.trim().length < 2) { setSearchResults(null); return }
|
||||
setSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
|
||||
setSearchResults(res.data)
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
} catch { setSearchResults([]) }
|
||||
finally { setSearching(false) }
|
||||
}, 350)
|
||||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, searchMode])
|
||||
|
|
@ -63,169 +173,199 @@ export default function QuizzesPage() {
|
|||
await api.delete(`/quizzes/${quizId}`)
|
||||
setQuizzes(prev => prev.filter(q => q.id !== quizId))
|
||||
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete quiz')
|
||||
}
|
||||
} catch (err) { alert(err.response?.data?.detail || 'Failed to delete quiz') }
|
||||
}
|
||||
|
||||
const handleCategoryChange = (quizId, catId) => {
|
||||
setQuizzes(prev => prev.map(q => q.id === quizId ? { ...q, category_id: catId } : q))
|
||||
}
|
||||
|
||||
const addCategory = async () => {
|
||||
if (!newCatName.trim()) return
|
||||
try {
|
||||
const res = await api.post('/categories/', { name: newCatName.trim() })
|
||||
setCategories(prev => [...prev, res.data])
|
||||
setNewCatName('')
|
||||
setAddingCat(false)
|
||||
} catch (err) { alert(err.response?.data?.detail || 'Failed to create category') }
|
||||
}
|
||||
|
||||
const deleteCategory = async (catId) => {
|
||||
if (!confirm('Delete category? Quizzes will become uncategorized.')) return
|
||||
try {
|
||||
await api.delete(`/categories/${catId}`)
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
setQuizzes(prev => prev.map(q => q.category_id === catId ? { ...q, category_id: null } : q))
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const isSearching = searchQuery.trim().length >= 2
|
||||
const displayList = isSearching ? searchResults : null
|
||||
const allSearchQuestions = searchResults?.flatMap(r => r.matching_questions.map(q => ({ ...q, quiz_title: r.quiz_title }))) ?? []
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
if (loading) return <div className="loading"><div className="spinner" /> Loading...</div>
|
||||
|
||||
// Group quizzes by category
|
||||
const catMap = {}
|
||||
for (const cat of categories) catMap[cat.id] = { ...cat, quizzes: [] }
|
||||
const uncategorized = []
|
||||
for (const quiz of quizzes) {
|
||||
if (quiz.category_id && catMap[quiz.category_id]) catMap[quiz.category_id].quizzes.push(quiz)
|
||||
else uncategorized.push(quiz)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{studyQuestion && (
|
||||
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8', fontSize: '1rem' }}>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search quizzes or questions..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '9px 13px 9px 36px',
|
||||
border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span>
|
||||
<input type="text" placeholder="Search quizzes or questions..."
|
||||
value={searchQuery} onChange={e => { setSearchQuery(e.target.value); setExpandedSearch(false) }}
|
||||
style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
|
||||
<button key={val} onClick={() => setSearchMode(val)}
|
||||
className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}>
|
||||
{label}
|
||||
</button>
|
||||
<button key={val} onClick={() => setSearchMode(val)} className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{searching ? 'Searching...' : searchResults
|
||||
? `${searchResults.length} result${searchResults.length !== 1 ? 's' : ''} for "${searchQuery}"`
|
||||
: ''}
|
||||
<div style={{ marginTop: 8, fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<span>{searching ? 'Searching...' : searchResults ? `${searchResults.length} quiz result${searchResults.length !== 1 ? 's' : ''}, ${allSearchQuestions.length} matching questions` : ''}</span>
|
||||
{!searching && allSearchQuestions.length > 0 && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setExpandedSearch(v => !v)}>
|
||||
{expandedSearch ? 'Show summary' : `View all ${allSearchQuestions.length} questions →`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{isSearching && searchResults !== null && (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">No matches found for "{searchQuery}"</div>
|
||||
{/* Expanded full question search results */}
|
||||
{isSearching && expandedSearch && allSearchQuestions.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: '0.95rem' }}>
|
||||
All matching questions for "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{searchResults.map(result => (
|
||||
<div key={result.quiz_id} className="card" style={{ marginBottom: 12, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: result.matching_questions.length > 0 ? 12 : 0 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>
|
||||
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: 8 }}>
|
||||
<span>{result.questions_count} questions</span>
|
||||
{result.match_type === 'both' && <span style={{ color: '#7c3aed' }}>• matched title + questions</span>}
|
||||
{result.match_type === 'questions' && <span style={{ color: '#2563eb' }}>• {result.matching_questions.length} matching question{result.matching_questions.length !== 1 ? 's' : ''}</span>}
|
||||
{result.match_type === 'title' && <span style={{ color: '#059669' }}>• title match</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isModerator && (
|
||||
<button onClick={e => deleteQuiz(e, result.quiz_id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
)}
|
||||
{allSearchQuestions.map((q, i) => (
|
||||
<div key={`${q.quiz_id}-${q.id}-${i}`} style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 16px', marginBottom: 8, display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 4 }}>{q.quiz_title}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text)', lineHeight: 1.5 }}>
|
||||
<HighlightText text={q.question_text.slice(0, 200) + (q.question_text.length > 200 ? '…' : '')} query={searchQuery} />
|
||||
</div>
|
||||
{result.matching_questions.slice(0, 3).map((q, i) => (
|
||||
<div key={q.id} style={{
|
||||
padding: '8px 12px', background: 'var(--bg)', borderRadius: 6,
|
||||
marginBottom: i < Math.min(result.matching_questions.length, 3) - 1 ? 6 : 0,
|
||||
fontSize: '0.85rem', color: 'var(--text-muted)',
|
||||
borderLeft: '3px solid var(--primary)',
|
||||
}}>
|
||||
<div style={{ marginBottom: q.options ? 4 : 0 }}>
|
||||
<HighlightText text={q.question_text.slice(0, 160) + (q.question_text.length > 160 ? '…' : '')} query={searchQuery} />
|
||||
</div>
|
||||
{q.options && q.options.some(o => o.toLowerCase().includes(searchQuery.toLowerCase())) && (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-subtle)', display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{q.options.filter(o => o.toLowerCase().includes(searchQuery.toLowerCase())).slice(0, 3).map((o, j) => (
|
||||
<span key={j} style={{ background: '#fef08a', padding: '1px 6px', borderRadius: 4, color: '#78350f', fontSize: '0.78rem' }}>
|
||||
<HighlightText text={o.slice(0, 80)} query={searchQuery} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{result.matching_questions.length > 3 && (
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 6, paddingLeft: 4 }}>
|
||||
+{result.matching_questions.length - 3} more matching question{result.matching_questions.length - 3 !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal quiz grid (hidden during active search) */}
|
||||
{/* Search summary results (compact) */}
|
||||
{isSearching && !expandedSearch && searchResults !== null && (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="card"><div className="empty-state">No matches for "{searchQuery}"</div></div>
|
||||
) : searchResults.map(result => (
|
||||
<div key={result.quiz_id} className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: result.matching_questions.length > 0 ? 10 : 0 }}>
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => navigate(`/quizzes/${result.quiz_id}`)}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 2 }}>
|
||||
<HighlightText text={result.quiz_title} query={result.match_type !== 'questions' ? searchQuery : ''} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{result.questions_count} questions
|
||||
{result.match_type !== 'title' && ` · ${result.matching_questions.length} question match${result.matching_questions.length !== 1 ? 'es' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
{isModerator && (
|
||||
<button onClick={e => deleteQuiz(e, result.quiz_id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
{result.matching_questions.slice(0, 2).map((q, i) => (
|
||||
<div key={q.id} style={{ padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, marginBottom: 6, fontSize: '0.85rem', color: 'var(--text-muted)', borderLeft: '3px solid var(--primary)', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}><HighlightText text={q.question_text.slice(0, 130) + (q.question_text.length > 130 ? '…' : '')} query={searchQuery} /></div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion({ ...q, quiz_title: result.quiz_title })} style={{ flexShrink: 0 }}>Study</button>
|
||||
</div>
|
||||
))}
|
||||
{result.matching_questions.length > 2 && (
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginTop: 4 }} onClick={() => setExpandedSearch(true)}>
|
||||
+{result.matching_questions.length - 2} more — view all questions →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Normal quiz grid */}
|
||||
{!isSearching && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>All Quizzes</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: 4 }}>
|
||||
{quizzes.length} quiz{quizzes.length !== 1 ? 'zes' : ''} available
|
||||
</p>
|
||||
</div>
|
||||
{/* Category management (moderators) */}
|
||||
{isModerator && (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: categories.length > 0 ? 12 : 0 }}>
|
||||
<h2 style={{ fontSize: '1rem' }}>Categories</h2>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setAddingCat(v => !v)}>+ Add Category</button>
|
||||
</div>
|
||||
{addingCat && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
placeholder="Category name..." onKeyDown={e => e.key === 'Enter' && addCategory()}
|
||||
style={{ flex: 1, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={addCategory}>Add</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setAddingCat(false); setNewCatName('') }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', background: 'var(--bg)', borderRadius: 20, border: '1px solid var(--border)', fontSize: '0.82rem' }}>
|
||||
<span>{cat.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>({cat.quiz_count})</span>
|
||||
<button onClick={() => deleteCategory(cat.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.8rem', padding: '0 2px', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quizzes.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<p>No quizzes yet. Upload a PDF and generate quizzes from sections.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card"><div className="empty-state">No quizzes yet. Upload a PDF and generate quizzes from sections.</div></div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16, marginTop: 8 }}>
|
||||
{quizzes.map(quiz => (
|
||||
<div key={quiz.id} onClick={() => navigate(`/quizzes/${quiz.id}`)}
|
||||
style={{
|
||||
background: 'var(--card-bg)', border: 'var(--card-border)',
|
||||
borderRadius: 'var(--card-radius)', padding: '20px 18px',
|
||||
cursor: 'pointer', transition: 'box-shadow 0.15s, transform 0.15s',
|
||||
display: 'flex', flexDirection: 'column', gap: 10, position: 'relative',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}
|
||||
>
|
||||
{isModerator && (
|
||||
<button onClick={e => deleteQuiz(e, quiz.id)} title="Delete quiz"
|
||||
style={{ position: 'absolute', top: 10, right: 10, background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', lineHeight: 1, padding: 4, borderRadius: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}>✕</button>
|
||||
)}
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
|
||||
{quiz.mode === 'learning' ? '📖' : '📝'}
|
||||
</div>
|
||||
<div style={{ paddingRight: isModerator ? 20 : 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', lineHeight: 1.3 }}>{quiz.title}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ fontSize: '0.82rem', color: '#64748b' }}>{quiz.questions_count} question{quiz.questions_count !== 1 ? 's' : ''}</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: 20, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', color: quiz.mode === 'learning' ? '#065f46' : '#3730a3' }}>
|
||||
{quiz.mode === 'learning' ? 'Learning' : 'Timed'}
|
||||
</span>
|
||||
{quiz.time_limit_minutes && <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{quiz.time_limit_minutes} min</span>}
|
||||
</div>
|
||||
<>
|
||||
{/* Categorised groups */}
|
||||
{Object.values(catMap).filter(c => c.quizzes.length > 0).map(cat => (
|
||||
<div key={cat.id} style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
|
||||
📁 {cat.name} <span style={{ fontWeight: 400 }}>({cat.quizzes.length})</span>
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{cat.quizzes.map(quiz => <QuizCard key={quiz.id} quiz={quiz} isModerator={isModerator} categories={categories} onDelete={deleteQuiz} onCategoryChange={handleCategoryChange} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Uncategorised */}
|
||||
{uncategorized.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{categories.length > 0 && (
|
||||
<h3 style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
|
||||
Uncategorized <span style={{ fontWeight: 400 }}>({uncategorized.length})</span>
|
||||
</h3>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{uncategorized.map(quiz => <QuizCard key={quiz.id} quiz={quiz} isModerator={isModerator} categories={categories} onDelete={deleteQuiz} onCategoryChange={handleCategoryChange} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -109,16 +109,16 @@ function AppearanceSection() {
|
|||
}
|
||||
|
||||
function NextcloudSection() {
|
||||
const [server, setServer] = useState(localStorage.getItem('nc_server') || 'https://cloud.danvics.com')
|
||||
const [username, setUsername] = useState(localStorage.getItem('nc_username') || '')
|
||||
const [password, setPassword] = useState(localStorage.getItem('nc_password') || '')
|
||||
const [server, setServer] = useState(sessionStorage.getItem('nc_server') || 'https://cloud.danvics.com')
|
||||
const [username, setUsername] = useState(sessionStorage.getItem('nc_username') || '')
|
||||
const [password, setPassword] = useState(sessionStorage.getItem('nc_password') || '')
|
||||
const [status, setStatus] = useState(null) // null | 'testing' | 'ok' | 'error'
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
|
||||
const save = () => {
|
||||
localStorage.setItem('nc_server', server)
|
||||
localStorage.setItem('nc_username', username)
|
||||
localStorage.setItem('nc_password', password)
|
||||
sessionStorage.setItem('nc_server', server)
|
||||
sessionStorage.setItem('nc_username', username)
|
||||
sessionStorage.setItem('nc_password', password)
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus(null), 2000)
|
||||
}
|
||||
|
|
@ -137,9 +137,9 @@ function NextcloudSection() {
|
|||
}
|
||||
|
||||
const clear = () => {
|
||||
localStorage.removeItem('nc_server')
|
||||
localStorage.removeItem('nc_username')
|
||||
localStorage.removeItem('nc_password')
|
||||
sessionStorage.removeItem('nc_server')
|
||||
sessionStorage.removeItem('nc_username')
|
||||
sessionStorage.removeItem('nc_password')
|
||||
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
|
||||
setStatus(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
|
|||
import api from '../api/client'
|
||||
|
||||
function NextcloudBrowser({ onFile }) {
|
||||
const ncServer = localStorage.getItem('nc_server') || ''
|
||||
const ncUser = localStorage.getItem('nc_username') || ''
|
||||
const ncPass = localStorage.getItem('nc_password') || ''
|
||||
const ncServer = sessionStorage.getItem('nc_server') || ''
|
||||
const ncUser = sessionStorage.getItem('nc_username') || ''
|
||||
const ncPass = sessionStorage.getItem('nc_password') || ''
|
||||
|
||||
const [path, setPath] = useState('/')
|
||||
const [items, setItems] = useState(null)
|
||||
|
|
@ -125,7 +125,7 @@ export default function UploadPage() {
|
|||
const fileRef = useRef()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hasNextcloud = !!localStorage.getItem('nc_username')
|
||||
const hasNextcloud = !!sessionStorage.getItem('nc_username')
|
||||
|
||||
const handleFile = (f, fromNextcloud = false) => {
|
||||
if (f && f.type === 'application/pdf') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue