Public Access
feat: add MyExams, MyResults, Settings, and Workspace components
Release Pipeline / build-and-release (push) Failing after 41s
Release Pipeline / build-and-release (push) Failing after 41s
- Implemented MyExams component for managing user exams, including loading, deleting, and creating new exams. - Created MyResults component to display user results with options to review and delete results. - Developed Settings component for updating user display name and changing password. - Introduced Workspace component to serve as a container for MyExams, MyResults, and Settings, with navigation between tabs.
This commit is contained in:
1 parent
ad777ef1c3
commit
62af83ce53
16 files changed
+2192
-47
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiFetch } from '../utils/api'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface SavedExam {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
question_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export default function MyExams() {
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<SavedExam[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await apiFetch<SavedExam[]>('/api/exams')
|
||||
setExams(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadExams()
|
||||
}, [loadExams])
|
||||
|
||||
const deleteExam = async (id: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
|
||||
setDeleteConfirm(null)
|
||||
void loadExams()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewExam = () => {
|
||||
navigate('/exam-editor/new')
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">My Workspace</div>
|
||||
<h2 className="workspace-section-title">My Exams</h2>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||
+ New Exam
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="workspace-alert workspace-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="workspace-empty">Loading…</div>
|
||||
) : exams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">No saved exams yet</div>
|
||||
<div className="workspace-empty-desc">
|
||||
Upload an exam from the home page, or create a new one to get started.
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
Create Your First Exam
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{exams.map((exam) => (
|
||||
<div key={exam.id} className="workspace-card">
|
||||
<div className="workspace-card-body">
|
||||
<div className="workspace-card-title">{exam.title}</div>
|
||||
{exam.subtitle && (
|
||||
<div className="workspace-card-sub">{exam.subtitle}</div>
|
||||
)}
|
||||
<div className="workspace-card-meta">
|
||||
<span>{exam.question_count} question{exam.question_count !== 1 ? 's' : ''}</span>
|
||||
<span>·</span>
|
||||
<span>Updated {formatDate(exam.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(exam.id)}
|
||||
title="Delete exam"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user