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
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
conn.execute(text("""
CREATE TABLE IF NOT EXISTS quiz_question_links (

View file

@ -21,6 +21,8 @@ class Quiz(Base):
mode = Column(String, default="timed") # timed, learning
skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts
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")
user = relationship("User", back_populates="quizzes")

View file

@ -1,6 +1,7 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
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")
def get_quiz_history(
db: Session = Depends(get_db),

View file

@ -1,4 +1,5 @@
import random
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
@ -248,12 +249,11 @@ def list_quizzes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all available quizzes. Admins/mods see all, users see all published."""
if current_user.is_moderator:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
else:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
return quizzes
"""List quizzes. Moderators see all; regular users only see published."""
q = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
if not current_user.is_moderator:
q = q.filter(Quiz.is_published == 1)
return q.order_by(Quiz.created_at.desc()).all()
@router.get("/{quiz_id}")
@ -264,7 +264,7 @@ def get_quiz(
current_user: User = Depends(get_current_user),
):
"""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:
raise HTTPException(status_code=404, detail="Quiz not found")
@ -441,17 +441,72 @@ def delete_quiz(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete quiz. Questions shared with other quizzes stay in the bank.
Questions only in this quiz are detached (source_quiz_id cleared) so they
remain in the bank instead of being deleted."""
"""Soft-delete quiz — moves to trash. Restore via PATCH /{quiz_id}/restore."""
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.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 datetime import datetime as dt
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
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 it (clear source_quiz_id) so it stays as a bank-only question
# Detach exclusive questions to bank
links = db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all()
for link in links:
other = db.query(QuizQuestionLink).filter(
@ -459,10 +514,9 @@ def delete_quiz(
QuizQuestionLink.quiz_id != quiz_id,
).count()
if other == 0:
# Detach from this quiz but keep in bank
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()

View file

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

View file

@ -104,23 +104,41 @@ def extract_quiz(
.replace("ltem", "Item")
.replace("ltcm", "Item"))
# --- Detect separate answer key section (e.g. PREP 2013) ---
# Scan document in 10-page steps to find where "Preferred Response:" first appears
# --- Detect PDF format ---
# 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
scan_step = 10
for scan_p in range(section.start_page, section.end_page, scan_step):
scan_end = min(scan_p + scan_step - 1, section.end_page)
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
scan_chunk = _normalize_ocr(raw_chunk)
if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower():
answer_section_start = max(section.start_page, scan_p - 5)
break
if not has_inline_answers:
scan_step = 10
for scan_p in range(section.start_page, section.end_page, scan_step):
scan_end = min(scan_p + scan_step - 1, section.end_page)
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
scan_chunk = _normalize_ocr(raw_chunk)
if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower():
answer_section_start = max(section.start_page, scan_p - 5)
break
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:
_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 QuestionBankPage from './pages/QuestionBankPage'
import JobsPage from './pages/JobsPage'
import TrashPage from './pages/TrashPage'
import QuizEditPage from './pages/QuizEditPage'
import VerifyEmailPage from './pages/VerifyEmailPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
@ -64,6 +65,7 @@ function AppRoutes() {
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
</Routes>
</div>
<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 { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
function ExtractionProgress({ jobId, onDone, onClose }) {
const [steps, setSteps] = useState([])
@ -209,7 +210,6 @@ export default function DocumentDetailPage() {
}
const deleteSection = async (sectionId) => {
if (!confirm('Delete this section? Associated quizzes will also be deleted.')) return
setDeletingSection(sectionId)
try {
await api.delete(`/documents/${id}/sections/${sectionId}`)
@ -234,7 +234,6 @@ export default function DocumentDetailPage() {
}
const deleteDoc = async () => {
if (!confirm('Delete this document and all its quizzes?')) return
await api.delete(`/documents/${id}`)
navigate('/')
}
@ -269,7 +268,7 @@ export default function DocumentDetailPage() {
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<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>
@ -429,14 +428,12 @@ export default function DocumentDetailPage() {
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => deleteSection(section.id)}
<ConfirmButton
onConfirm={() => deleteSection(section.id)}
label="Delete section"
confirmLabel="Yes, delete"
disabled={deletingSection === section.id}
title="Delete this section"
>
{deletingSection === section.id ? '…' : 'Delete'}
</button>
/>
</>
)}
</div>

View file

@ -315,30 +315,13 @@ export default function QuestionBankPage() {
} catch (err) { alert(err.response?.data?.detail || 'Failed') }
}
const deleteCategory = async (catId) => {
const cat = categories.find(c => c.id === catId)
const otherCats = categories.filter(c => c.id !== catId)
const count = cat?.question_count || 0
const [deletingCatId, setDeletingCatId] = useState(null)
const [moveToCatId, setMoveToCatId] = useState('')
if (count === 0) {
if (!confirm(`Delete category "${cat?.name}"?`)) return
try {
await api.delete(`/question-categories/${catId}`)
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
const confirmDeleteCategory = async () => {
const catId = deletingCatId
const moveTo = moveToCatId ? parseInt(moveToCatId) : undefined
setDeletingCatId(null); setMoveToCatId('')
try {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId))
@ -347,8 +330,39 @@ export default function QuestionBankPage() {
} catch { }
}
const deleteCategory = (catId) => {
setDeletingCatId(catId)
setMoveToCatId('')
}
return (
<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)} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
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 { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
@ -41,7 +42,6 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
}
const del = async () => {
if (!confirm('Delete this question? This cannot be undone.')) return
try {
await api.delete(`/quizzes/${quizId}/questions/${q.id}`)
onDeleted(q.id)
@ -59,7 +59,7 @@ function QuestionEditor({ q, quizId, onSaved, onDeleted }) {
</p>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<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>
{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 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 (
<div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -86,6 +92,17 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
</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>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
@ -193,6 +210,23 @@ useEffect(() => {
return () => clearInterval(timerRef.current)
}, [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 safeNavigate = (targetIdx) => {
@ -209,6 +243,7 @@ useEffect(() => {
await new Promise(r => setTimeout(r, 1200))
}
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${id}`).catch(() => {})
setSubmitting(true)
try {
const submission = {
@ -225,6 +260,18 @@ useEffect(() => {
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
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 (
<div>
{isModerator && (
@ -232,7 +279,7 @@ useEffect(() => {
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div>
)}
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} />
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onResume={resumeQuiz} />
</div>
)

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span>
@ -75,6 +76,15 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
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 (
<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)' }}
@ -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' }}>
{isModerator && (
<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)}
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)'}
@ -100,10 +115,12 @@ function QuizCard({ quiz, isModerator, categories, onDelete, onCategoryChange })
</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>
<ConfirmButton
label="✕" confirmLabel="Move to Trash?" cancelLabel="✕"
className="btn-sm" confirmClassName="btn btn-danger btn-sm"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 6px' }}
onConfirm={() => onDelete(quiz.id)}
/>
</div>
)}
<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)
}, [searchQuery, searchMode])
const deleteQuiz = async (e, quizId) => {
e.stopPropagation()
if (!confirm('Delete this quiz and all its attempts?')) return
const deleteQuiz = async (quizId) => {
try {
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) { console.error(err) }
}
const handleCategoryChange = (quizId, catId) => {
setQuizzes(prev => prev.map(q => q.id === quizId ? { ...q, category_id: catId } : q))
const handleCategoryChange = (quizId, catId, isPublished) => {
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 () => {
@ -191,7 +210,6 @@ export default function QuizzesPage() {
}
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))
@ -283,8 +301,9 @@ export default function QuizzesPage() {
</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>
<ConfirmButton label="✕" confirmLabel="Trash?" cancelLabel="✕"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '1rem', padding: '4px 8px' }}
onConfirm={() => deleteQuiz(result.quiz_id)} />
)}
</div>
{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: '/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 => (
<Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
<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>
)
}