Fix quiz delete (500 error), add trash bin, quiz resume, hide/publish, remove JS dialogs

Critical bug fix:
- DELETE /quizzes/{id} was returning 500 due to {\"quiz_id\": None} update using
  the DB column name instead of Python attribute name (source_quiz_id).
  Fixed with {QuestionModel.source_quiz_id: None, synchronize_session=False}

Extraction fix:
- Two-phase extraction was incorrectly triggering for PREP 2012/2014 format
  documents that have 'Preferred Response:' in their explanation text.
  Fix: check for inline 'Correct Answer:' first — if found, always use standard
  extraction regardless of 'Preferred Response:' appearing elsewhere.

Quiz trash bin:
- DELETE /quizzes/{id} now soft-deletes (sets deleted_at)
- GET /quizzes/trash — list deleted quizzes (moderator)
- PATCH /quizzes/{id}/restore — restore from trash
- DELETE /quizzes/{id}/permanent — permanent delete (must be in trash first)
- TrashPage.jsx — accessible via Settings → Admin → Trash

Hide/publish quizzes:
- is_published column on quizzes (1=visible, 0=hidden)
- PATCH /quizzes/{id}/publish?published=false — hide from regular users
- Moderators see all quizzes; regular users only see published
- 👁/🙈 toggle button per quiz card (moderators only)

Quiz progress resume (cross-browser via Redis):
- POST /attempts/progress — save {answers, current_idx, mode} to Redis (7 days)
- GET /attempts/progress?quiz_id=N — retrieve saved progress
- DELETE /attempts/progress/{quiz_id} — clear on submit
- QuizPage auto-saves to Redis every 1.5s (debounced) while in progress
- ModeSelectScreen loads saved progress from server, shows Resume button
- Works across browsers, devices, and after logout

Delete attempt:
- DELETE /attempts/{id} — user can delete own attempt + clears reminders for that quiz

ConfirmButton component:
- Replaces all window.confirm() / window.prompt() across the app
- Double-click pattern: first click shows [Confirm] [Cancel] inline
- Applied to: QuizzesPage, DocumentDetailPage, QuizEditPage, QuestionBankPage

Category delete (QuestionBankPage):
- window.prompt() replaced with inline modal dialog with select dropdown
- User chooses where to move questions before deletion

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 04:51:41 +02:00
parent 5a2fc7e187
commit 45add79db3
15 changed files with 467 additions and 81 deletions

View file

@ -161,6 +161,10 @@ def setup_pgvector():
# Fix email collation to avoid en_US.utf8 B-tree index corruption # Fix email collation to avoid en_US.utf8 B-tree index corruption
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"')) conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
# Soft delete + publish control for quizzes
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_published INTEGER DEFAULT 1"))
# Junction table: quiz ↔ question many-to-many # Junction table: quiz ↔ question many-to-many
conn.execute(text(""" conn.execute(text("""
CREATE TABLE IF NOT EXISTS quiz_question_links ( CREATE TABLE IF NOT EXISTS quiz_question_links (

View file

@ -21,6 +21,8 @@ class Quiz(Base):
mode = Column(String, default="timed") # timed, learning mode = Column(String, default="timed") # timed, learning
skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only)
section = relationship("Section", back_populates="quizzes") section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes") user = relationship("User", back_populates="quizzes")

View file

@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
@ -156,6 +157,120 @@ def list_attempts(
] ]
class ProgressSave(BaseModel):
quiz_id: int
attempt_id: int
answers: dict # {question_id: answer}
current_idx: int
mode: str
@router.post("/progress")
def save_progress(
data: ProgressSave,
current_user: User = Depends(get_current_user),
):
"""Save in-progress quiz answers to Redis (survives logout/browser change)."""
try:
import redis as redis_lib, json as _json
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{data.quiz_id}"
r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days
"attempt_id": data.attempt_id,
"answers": data.answers,
"current_idx": data.current_idx,
"mode": data.mode,
}))
except Exception:
pass # Redis unavailable — degrade gracefully
return {"saved": True}
@router.get("/progress")
def get_progress(
quiz_id: int,
current_user: User = Depends(get_current_user),
):
"""Retrieve in-progress quiz answers from Redis."""
try:
import redis as redis_lib, json as _json
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{quiz_id}"
data = r.get(key)
if data:
return _json.loads(data)
except Exception:
pass
return None
@router.delete("/progress/{quiz_id}", status_code=204)
def clear_progress(
quiz_id: int,
current_user: User = Depends(get_current_user),
):
"""Clear saved progress when quiz is submitted."""
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{quiz_id}")
except Exception:
pass
@router.delete("/{attempt_id}", status_code=204)
def delete_attempt(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete own attempt + answers + reminders for that quiz."""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
if not attempt:
raise HTTPException(status_code=404, detail="Attempt not found")
quiz_id = attempt.quiz_id
# Delete all attempts for this quiz by this user (wipe history)
from app.models.reminder import ReminderSchedule
db.query(ReminderSchedule).filter(
ReminderSchedule.user_id == current_user.id,
ReminderSchedule.quiz_id == quiz_id,
).delete()
db.delete(attempt)
db.commit()
@router.get("/{attempt_id}/in-progress")
def get_in_progress_attempt(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the latest incomplete attempt for a quiz, or null."""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.is_(None),
).order_by(QuizAttempt.started_at.desc()).first()
if not attempt:
return None
return AttemptResponse(
id=attempt.id,
quiz_id=attempt.quiz_id,
score=0,
total_questions=attempt.total_questions,
percentage=0.0,
started_at=attempt.started_at,
completed_at=None,
)
@router.get("/history") @router.get("/history")
def get_quiz_history( def get_quiz_history(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View file

@ -1,4 +1,5 @@
import random import random
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -248,12 +249,11 @@ def list_quizzes(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""List all available quizzes. Admins/mods see all, users see all published.""" """List quizzes. Moderators see all; regular users only see published."""
if current_user.is_moderator: q = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all() if not current_user.is_moderator:
else: q = q.filter(Quiz.is_published == 1)
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all() return q.order_by(Quiz.created_at.desc()).all()
return quizzes
@router.get("/{quiz_id}") @router.get("/{quiz_id}")
@ -264,7 +264,7 @@ def get_quiz(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Get quiz for taking. study=true forces answers/explanations to be included.""" """Get quiz for taking. study=true forces answers/explanations to be included."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz: if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found") raise HTTPException(status_code=404, detail="Quiz not found")
@ -441,17 +441,72 @@ def delete_quiz(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_moderator), current_user: User = Depends(require_moderator),
): ):
"""Delete quiz. Questions shared with other quizzes stay in the bank. """Soft-delete quiz — moves to trash. Restore via PATCH /{quiz_id}/restore."""
Questions only in this quiz are detached (source_quiz_id cleared) so they quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
remain in the bank instead of being deleted.""" if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
quiz.deleted_at = datetime.utcnow()
db.commit()
@router.patch("/{quiz_id}/publish")
def set_quiz_published(
quiz_id: int,
published: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Hide (published=false) or show (published=true) a quiz for regular users."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
quiz.is_published = 1 if published else 0
db.commit()
return {"quiz_id": quiz_id, "is_published": quiz.is_published}
@router.get("/trash", response_model=list[QuizResponse])
def list_trash(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""List soft-deleted quizzes — admin/moderator only."""
return db.query(Quiz).filter(Quiz.deleted_at.isnot(None)).order_by(Quiz.deleted_at.desc()).all()
@router.patch("/{quiz_id}/restore", response_model=QuizResponse)
def restore_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Restore a soft-deleted quiz from trash."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.isnot(None)).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found in trash")
quiz.deleted_at = None
db.commit()
db.refresh(quiz)
return quiz
@router.delete("/{quiz_id}/permanent", status_code=204)
def permanently_delete_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Permanently delete a quiz that's already in trash. Questions stay in bank."""
from app.models.quiz_question_link import QuizQuestionLink from app.models.quiz_question_link import QuizQuestionLink
from datetime import datetime as dt
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz: if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found") raise HTTPException(status_code=404, detail="Quiz not found")
if quiz.deleted_at is None:
raise HTTPException(status_code=400, detail="Move to trash first before permanently deleting")
# For each question linked to this quiz: if no other quiz uses it, # Detach exclusive questions to bank
# detach it (clear source_quiz_id) so it stays as a bank-only question
links = db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all() links = db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all()
for link in links: for link in links:
other = db.query(QuizQuestionLink).filter( other = db.query(QuizQuestionLink).filter(
@ -459,10 +514,9 @@ def delete_quiz(
QuizQuestionLink.quiz_id != quiz_id, QuizQuestionLink.quiz_id != quiz_id,
).count() ).count()
if other == 0: if other == 0:
# Detach from this quiz but keep in bank
db.query(QuestionModel).filter(QuestionModel.id == link.question_id).update( db.query(QuestionModel).filter(QuestionModel.id == link.question_id).update(
{"quiz_id": None} # source_quiz_id → null {QuestionModel.source_quiz_id: None}, synchronize_session=False
) )
db.delete(quiz) # cascades quiz_question_links db.delete(quiz)
db.commit() db.commit()

View file

@ -40,6 +40,8 @@ class QuizResponse(BaseModel):
skipped_questions: str | None = None skipped_questions: str | None = None
category_id: int | None = None category_id: int | None = None
created_at: datetime created_at: datetime
deleted_at: datetime | None = None
is_published: int = 1
class Config: class Config:
from_attributes = True from_attributes = True

View file

@ -104,23 +104,41 @@ def extract_quiz(
.replace("ltem", "Item") .replace("ltem", "Item")
.replace("ltcm", "Item")) .replace("ltcm", "Item"))
# --- Detect separate answer key section (e.g. PREP 2013) --- # --- Detect PDF format ---
# Scan document in 10-page steps to find where "Preferred Response:" first appears # First check if document has INLINE correct answers (standard PREP 2012 format).
# If yes → always use standard extraction, even if "Preferred Response:" appears
# later in explanations.
first_50_content = vector_service.get_pages_text(
document_id=section.document_id,
start_page=section.start_page,
end_page=min(section.start_page + 49, section.end_page),
)
has_inline_answers = bool(first_50_content and (
"Correct Answer:" in first_50_content or
"correct answer:" in first_50_content.lower()
))
# Only look for end-of-document answer key if there are NO inline answers
answer_section_start = None answer_section_start = None
scan_step = 10 if not has_inline_answers:
for scan_p in range(section.start_page, section.end_page, scan_step): scan_step = 10
scan_end = min(scan_p + scan_step - 1, section.end_page) for scan_p in range(section.start_page, section.end_page, scan_step):
raw_chunk = vector_service.get_pages_text( scan_end = min(scan_p + scan_step - 1, section.end_page)
document_id=section.document_id, start_page=scan_p, end_page=scan_end, raw_chunk = vector_service.get_pages_text(
) document_id=section.document_id, start_page=scan_p, end_page=scan_end,
if not raw_chunk: )
continue if not raw_chunk:
scan_chunk = _normalize_ocr(raw_chunk) continue
if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower(): scan_chunk = _normalize_ocr(raw_chunk)
answer_section_start = max(section.start_page, scan_p - 5) if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower():
break answer_section_start = max(section.start_page, scan_p - 5)
break
has_end_answer_key = answer_section_start is not None has_end_answer_key = answer_section_start is not None
if has_inline_answers:
_push_step(r, job_id, "ai", "Detected inline answer format (Correct Answer: X). Using standard extraction.")
elif has_end_answer_key:
pass # logged below
if has_end_answer_key: if has_end_answer_key:
_push_step(r, job_id, "ai", f"Detected separate answer key section starting around page {answer_section_start}. Using two-phase extraction.") _push_step(r, job_id, "ai", f"Detected separate answer key section starting around page {answer_section_start}. Using two-phase extraction.")

View file

@ -15,6 +15,7 @@ import AccountPage from './pages/AccountPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import QuestionBankPage from './pages/QuestionBankPage' import QuestionBankPage from './pages/QuestionBankPage'
import JobsPage from './pages/JobsPage' import JobsPage from './pages/JobsPage'
import TrashPage from './pages/TrashPage'
import QuizEditPage from './pages/QuizEditPage' import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage' import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage' import ForgotPasswordPage from './pages/ForgotPasswordPage'
@ -64,6 +65,7 @@ function AppRoutes() {
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} /> <Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} /> <Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
</Routes> </Routes>
</div> </div>
<Footer /> <Footer />

View file

@ -0,0 +1,49 @@
import { useState } from 'react'
/**
* Replaces window.confirm() shows inline "Are you sure?" buttons on first click.
* Usage:
* <ConfirmButton onConfirm={() => doDelete()} danger label="Delete" confirmLabel="Yes, delete" />
*/
export default function ConfirmButton({
onConfirm,
label = 'Delete',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
className = 'btn btn-danger btn-sm',
confirmClassName = 'btn btn-danger btn-sm',
disabled = false,
style = {},
}) {
const [pending, setPending] = useState(false)
if (pending) {
return (
<span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<button
className={confirmClassName}
onClick={(e) => { e.stopPropagation(); setPending(false); onConfirm() }}
>
{confirmLabel}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={(e) => { e.stopPropagation(); setPending(false) }}
>
{cancelLabel}
</button>
</span>
)
}
return (
<button
className={className}
style={style}
disabled={disabled}
onClick={(e) => { e.stopPropagation(); setPending(true) }}
>
{label}
</button>
)
}

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
function ExtractionProgress({ jobId, onDone, onClose }) { function ExtractionProgress({ jobId, onDone, onClose }) {
const [steps, setSteps] = useState([]) const [steps, setSteps] = useState([])
@ -209,7 +210,6 @@ export default function DocumentDetailPage() {
} }
const deleteSection = async (sectionId) => { const deleteSection = async (sectionId) => {
if (!confirm('Delete this section? Associated quizzes will also be deleted.')) return
setDeletingSection(sectionId) setDeletingSection(sectionId)
try { try {
await api.delete(`/documents/${id}/sections/${sectionId}`) await api.delete(`/documents/${id}/sections/${sectionId}`)
@ -234,7 +234,6 @@ export default function DocumentDetailPage() {
} }
const deleteDoc = async () => { const deleteDoc = async () => {
if (!confirm('Delete this document and all its quizzes?')) return
await api.delete(`/documents/${id}`) await api.delete(`/documents/${id}`)
navigate('/') navigate('/')
} }
@ -269,7 +268,7 @@ export default function DocumentDetailPage() {
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className={`badge badge-${doc.status}`}>{doc.status}</span> <span className={`badge badge-${doc.status}`}>{doc.status}</span>
{isModerator && <button className="btn btn-danger btn-sm" onClick={deleteDoc}>Delete</button>} {isModerator && <ConfirmButton onConfirm={deleteDoc} label="Delete" confirmLabel="Yes, delete document" />}
</div> </div>
</div> </div>
@ -429,14 +428,12 @@ export default function DocumentDetailPage() {
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</> <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'} ) : 'Extract & Create Quiz'}
</button> </button>
<button <ConfirmButton
className="btn btn-danger btn-sm" onConfirm={() => deleteSection(section.id)}
onClick={() => deleteSection(section.id)} label="Delete section"
confirmLabel="Yes, delete"
disabled={deletingSection === section.id} disabled={deletingSection === section.id}
title="Delete this section" />
>
{deletingSection === section.id ? '…' : 'Delete'}
</button>
</> </>
)} )}
</div> </div>

View file

@ -315,30 +315,13 @@ export default function QuestionBankPage() {
} catch (err) { alert(err.response?.data?.detail || 'Failed') } } catch (err) { alert(err.response?.data?.detail || 'Failed') }
} }
const deleteCategory = async (catId) => { const [deletingCatId, setDeletingCatId] = useState(null)
const cat = categories.find(c => c.id === catId) const [moveToCatId, setMoveToCatId] = useState('')
const otherCats = categories.filter(c => c.id !== catId)
const count = cat?.question_count || 0
if (count === 0) { const confirmDeleteCategory = async () => {
if (!confirm(`Delete category "${cat?.name}"?`)) return const catId = deletingCatId
try { const moveTo = moveToCatId ? parseInt(moveToCatId) : undefined
await api.delete(`/question-categories/${catId}`) setDeletingCatId(null); setMoveToCatId('')
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
} catch { }
return
}
// Has questions ask where to move them
const options = ['Leave uncategorized', ...otherCats.map(c => c.name)]
const choice = window.prompt(
`"${cat?.name}" has ${count} question${count !== 1 ? 's' : ''}.\n\nWhere should they go?\n${options.map((o, i) => `${i}: ${o}`).join('\n')}\n\nEnter number (or Cancel to abort):`
)
if (choice === null) return
const idx = parseInt(choice)
if (isNaN(idx) || idx < 0 || idx >= options.length) return
const moveTo = idx === 0 ? undefined : otherCats[idx - 1]?.id
try { try {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} }) await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId)) setCategories(prev => prev.filter(c => c.id !== catId))
@ -347,8 +330,39 @@ export default function QuestionBankPage() {
} catch { } } catch { }
} }
const deleteCategory = (catId) => {
setDeletingCatId(catId)
setMoveToCatId('')
}
return ( return (
<div> <div>
{/* Delete category dialog */}
{deletingCatId && (() => {
const cat = categories.find(c => c.id === deletingCatId)
const others = categories.filter(c => c.id !== deletingCatId)
return (
<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: 12, fontSize: '1.1rem' }}>Delete "{cat?.name}"?</h2>
{cat?.question_count > 0 && (
<div className="form-group">
<label>Move {cat.question_count} questions to:</label>
<select value={moveToCatId} onChange={e => setMoveToCatId(e.target.value)}>
<option value="">Leave uncategorized</option>
{others.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="btn btn-danger" onClick={confirmDeleteCategory}>Delete category</button>
<button className="btn btn-secondary" onClick={() => { setDeletingCatId(null); setMoveToCatId('') }}>Cancel</button>
</div>
</div>
</div>
)
})()}
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />} {studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories} {editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))} onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
@ -41,7 +42,6 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
} }
const del = async () => { const del = async () => {
if (!confirm('Delete this question? This cannot be undone.')) return
try { try {
await api.delete(`/quizzes/${quizId}/questions/${q.id}`) await api.delete(`/quizzes/${quizId}/questions/${q.id}`)
onDeleted(q.id) onDeleted(q.id)
@ -59,7 +59,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
</p> </p>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button> <button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
<button className="btn btn-danger btn-sm" onClick={del}>Delete</button> <ConfirmButton onConfirm={del} label="Delete" confirmLabel="Yes, delete" />
</div> </div>
</div> </div>
{q.options && ( {q.options && (

View file

@ -71,9 +71,15 @@ function TimerDisplay({ seconds, total }) {
) )
} }
function ModeSelectScreen({ quiz, voices, onStart }) { function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const skipped = quiz.skipped_questions ? JSON.parse(quiz.skipped_questions) : [] const [saved, setSaved] = useState(null)
useEffect(() => {
api.get('/attempts/progress', { params: { quiz_id: quiz.id } })
.then(res => { if (res.data) setSaved(res.data) })
.catch(() => {})
}, [quiz.id])
return ( return (
<div style={{ maxWidth: 520, margin: '40px auto' }}> <div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -86,6 +92,17 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
</p> </p>
{saved && saved.attemptId && (
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--option-sel-bd)', borderRadius: 8, padding: '12px 14px', marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>You have an in-progress attempt</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Q{(saved.current_idx ?? 0) + 1} · {Object.keys(saved.answers || {}).length} answered
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => onResume(saved)}>Resume </button>
</div>
)}
<p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p> <p style={{ fontWeight: 600, marginBottom: 16, color: '#374151' }}>Choose how to take this quiz:</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}> <div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[ {[
@ -193,6 +210,23 @@ useEffect(() => {
return () => clearInterval(timerRef.current) return () => clearInterval(timerRef.current)
}, [timeLeft === null]) }, [timeLeft === null])
// Save progress to Redis (survives logout/browser change)
const saveProgressRef = useRef(null)
useEffect(() => {
if (!attemptId || !quizMode) return
clearTimeout(saveProgressRef.current)
saveProgressRef.current = setTimeout(() => {
api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
}).catch(() => {})
}, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const safeNavigate = (targetIdx) => { const safeNavigate = (targetIdx) => {
@ -209,6 +243,7 @@ useEffect(() => {
await new Promise(r => setTimeout(r, 1200)) await new Promise(r => setTimeout(r, 1200))
} }
clearInterval(timerRef.current) clearInterval(timerRef.current)
api.delete(`/attempts/progress/${id}`).catch(() => {})
setSubmitting(true) setSubmitting(true)
try { try {
const submission = { const submission = {
@ -225,6 +260,18 @@ useEffect(() => {
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null if (!quiz) return null
const resumeQuiz = (saved) => {
const mode = saved.mode || saved.quizMode
setQuizMode(mode)
setAnswers(saved.answers || {})
setCurrentIdx(saved.current_idx ?? saved.currentIdx ?? 0)
setAttemptId(saved.attempt_id || saved.attemptId)
hasStarted.current = true
if (mode === 'study') {
api.get(`/quizzes/${id}?study=true`).then(r => setQuiz(r.data)).catch(() => {})
}
}
if (!quizMode) return ( if (!quizMode) return (
<div> <div>
{isModerator && ( {isModerator && (
@ -232,7 +279,7 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link> <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} /> <ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onResume={resumeQuiz} />
</div> </div>
) )

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
function HighlightText({ text, query }) { function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span> if (!query || !text) return <span>{text}</span>
@ -75,6 +76,15 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
setShowCatMenu(false) setShowCatMenu(false)
} }
const togglePublish = async (e) => {
e.stopPropagation()
try {
const newVal = quiz.is_published === 0
await api.patch(`/quizzes/${quiz.id}/publish`, null, { params: { published: newVal } })
onCategoryChange(quiz.id, quiz.category_id, newVal ? 1 : 0) // reuse callback to update state
} catch { }
}
return ( return (
<div onClick={() => navigate(`/quizzes/${quiz.id}`)} <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)' }} 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)' }}
@ -82,7 +92,12 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}> onMouseLeave={e => { e.currentTarget.style.boxShadow = 'var(--card-shadow)'; e.currentTarget.style.transform = 'none' }}>
{isModerator && ( {isModerator && (
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}> <div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', gap: 4 }} onClick={e => e.stopPropagation()}>
<div style={{ position: 'relative' }}> <button title={quiz.is_published === 0 ? 'Hidden from users — click to publish' : 'Visible — click to hide'} onClick={togglePublish}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: quiz.is_published === 0 ? '#ef4444' : '#cbd5e1', fontSize: '0.9rem', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
{quiz.is_published === 0 ? '🙈' : '👁'}
</button>
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
<button title="Move to category" onClick={() => setShowCatMenu(v => !v)} <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 }} 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)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
@ -100,10 +115,12 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
</div> </div>
)} )}
</div> </div>
<button onClick={e => onDelete(e, quiz.id)} title="Delete quiz" <ConfirmButton
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 6px', borderRadius: 4 }} label="✕" confirmLabel="Move to Trash?" cancelLabel="✕"
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} className="btn-sm" confirmClassName="btn btn-danger btn-sm"
onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button> style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 6px' }}
onConfirm={() => onDelete(quiz.id)}
/>
</div> </div>
)} )}
<div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}> <div style={{ width: 40, height: 40, borderRadius: 10, background: quiz.mode === 'learning' ? '#d1fae5' : '#e0e7ff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.4rem' }}>
@ -166,18 +183,20 @@ export default function QuizzesPage() {
return () => clearTimeout(debounceRef.current) return () => clearTimeout(debounceRef.current)
}, [searchQuery, searchMode]) }, [searchQuery, searchMode])
const deleteQuiz = async (e, quizId) => { const deleteQuiz = async (quizId) => {
e.stopPropagation()
if (!confirm('Delete this quiz and all its attempts?')) return
try { try {
await api.delete(`/quizzes/${quizId}`) await api.delete(`/quizzes/${quizId}`)
setQuizzes(prev => prev.filter(q => q.id !== quizId)) setQuizzes(prev => prev.filter(q => q.id !== quizId))
if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_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) { console.error(err) }
} }
const handleCategoryChange = (quizId, catId) => { const handleCategoryChange = (quizId, catId, isPublished) => {
setQuizzes(prev => prev.map(q => q.id === quizId ? { ...q, category_id: catId } : q)) setQuizzes(prev => prev.map(q => q.id === quizId ? {
...q,
category_id: catId !== undefined ? catId : q.category_id,
is_published: isPublished !== undefined ? isPublished : q.is_published,
} : q))
} }
const addCategory = async () => { const addCategory = async () => {
@ -191,7 +210,6 @@ export default function QuizzesPage() {
} }
const deleteCategory = async (catId) => { const deleteCategory = async (catId) => {
if (!confirm('Delete category? Quizzes will become uncategorized.')) return
try { try {
await api.delete(`/categories/${catId}`) await api.delete(`/categories/${catId}`)
setCategories(prev => prev.filter(c => c.id !== catId)) setCategories(prev => prev.filter(c => c.id !== catId))
@ -283,8 +301,9 @@ export default function QuizzesPage() {
</div> </div>
</div> </div>
{isModerator && ( {isModerator && (
<button onClick={e => deleteQuiz(e, result.quiz_id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }} <ConfirmButton label="✕" confirmLabel="Trash?" cancelLabel="✕"
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button> style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
onConfirm={() => deleteQuiz(result.quiz_id)} />
)} )}
</div> </div>
{result.matching_questions.slice(0, 2).map((q, i) => ( {result.matching_questions.slice(0, 2).map((q, i) => (

View file

@ -185,6 +185,8 @@ function AdminSection() {
{[ {[
{ to: '/admin', icon: '👥', label: 'User Management', desc: 'Manage accounts and roles' }, { to: '/admin', icon: '👥', label: 'User Management', desc: 'Manage accounts and roles' },
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' }, { to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' },
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' },
].map(item => ( ].map(item => (
<Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}> <Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
<div style={{ <div style={{

View file

@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
export default function TrashPage() {
const [trashed, setTrashed] = useState([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
api.get('/quizzes/trash')
.then(res => setTrashed(res.data))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const restore = async (id) => {
await api.patch(`/quizzes/${id}/restore`)
setTrashed(prev => prev.filter(q => q.id !== id))
}
const permanentDelete = async (id) => {
await api.delete(`/quizzes/${id}/permanent`)
setTrashed(prev => prev.filter(q => q.id !== id))
}
if (loading) return <div className="loading"><div className="spinner" /></div>
return (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
<div className="card" style={{ marginBottom: 16 }}>
<h2>Trash</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginTop: 4 }}>
Deleted quizzes restore or permanently delete.
</p>
</div>
{trashed.length === 0 ? (
<div className="card"><div className="empty-state">Trash is empty</div></div>
) : trashed.map(quiz => (
<div className="card" key={quiz.id} style={{ marginBottom: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div>
<div style={{ fontWeight: 600 }}>{quiz.title}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{quiz.questions_count} questions · deleted {new Date(quiz.deleted_at).toLocaleDateString()}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => restore(quiz.id)}>Restore</button>
<ConfirmButton
label="Delete forever"
confirmLabel="Yes, permanently delete"
onConfirm={() => permanentDelete(quiz.id)}
/>
</div>
</div>
))}
</div>
)
}