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:
parent
5a2fc7e187
commit
45add79db3
15 changed files with 467 additions and 81 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
49
frontend/src/components/ConfirmButton.jsx
Normal file
49
frontend/src/components/ConfirmButton.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
61
frontend/src/pages/TrashPage.jsx
Normal file
61
frontend/src/pages/TrashPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue