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:
Daniel 2026-03-31 20:08:05 +02:00
parent 7817f89849
commit 9af63e67b5
19 changed files with 926 additions and 216 deletions

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud
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")

View file

@ -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")

View 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")

View file

@ -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:

View file

@ -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

View 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}

View 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}

View file

@ -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:

View file

@ -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}"""

View file

@ -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;

View file

@ -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 />

View file

@ -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>

View file

@ -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>

View 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>
)
}

View file

@ -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) => {

View file

@ -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'}

View file

@ -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>
)}
</>
)}
</>
)}

View file

@ -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)
}

View file

@ -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') {