Public Access
827 lines
35 KiB
TypeScript
827 lines
35 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useTranslation } from 'react-i18next'
|
||
import * as examApi from '../api/examApi'
|
||
import * as collectionApi from '../api/collectionApi'
|
||
import type { Exam } from '../types'
|
||
|
||
interface SavedExam {
|
||
id: number
|
||
title: string
|
||
subtitle: string | null
|
||
question_count: number
|
||
created_at: number
|
||
updated_at: number
|
||
share_token: string | null
|
||
is_favorite: number
|
||
tags: string
|
||
collection_id: number | null
|
||
}
|
||
|
||
interface Props {
|
||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||
}
|
||
|
||
type CollectionFilter = number | null | 'all' | 'uncategorized'
|
||
|
||
export default function MyExams({ onLoadExam }: Readonly<Props>) {
|
||
const { t } = useTranslation()
|
||
const navigate = useNavigate()
|
||
const [exams, setExams] = useState<SavedExam[]>([])
|
||
const [collections, setCollections] = useState<collectionApi.Collection[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||
const [startingId, setStartingId] = useState<number | null>(null)
|
||
const [sharingId, setSharingId] = useState<number | null>(null)
|
||
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
|
||
const [exportingId, setExportingId] = useState<number | null>(null)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [activeFilter, setActiveFilter] = useState<CollectionFilter>('all')
|
||
const [showNewCollection, setShowNewCollection] = useState(false)
|
||
const [newCollectionName, setNewCollectionName] = useState('')
|
||
const [creatingCollection, setCreatingCollection] = useState(false)
|
||
const [movingExamId, setMovingExamId] = useState<number | null>(null)
|
||
const [menuOpenId, setMenuOpenId] = useState<number | null>(null)
|
||
const [menuSection, setMenuSection] = useState<'main' | 'move' | 'share'>('main')
|
||
|
||
const closeMenu = () => { setMenuOpenId(null); setMenuSection('main') }
|
||
const openMenu = (id: number) => { setMenuOpenId(id); setMenuSection('main') }
|
||
const [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
|
||
const [editingCollectionName, setEditingCollectionName] = useState('')
|
||
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
|
||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||
const [batchMoveOpen, setBatchMoveOpen] = useState(false)
|
||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||
const [batchExporting, setBatchExporting] = useState(false)
|
||
const [batchMoving, setBatchMoving] = useState(false)
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const [examData, collectionData] = await Promise.all([
|
||
examApi.listExams(),
|
||
collectionApi.listCollections(),
|
||
])
|
||
setExams(examData)
|
||
setCollections(collectionData)
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void loadData()
|
||
}, [loadData])
|
||
|
||
const deleteExam = async (id: number) => {
|
||
try {
|
||
await examApi.deleteExam(id)
|
||
setDeleteConfirm(null)
|
||
void loadData()
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
}
|
||
}
|
||
|
||
const createNewExam = () => {
|
||
navigate('/exam-editor/new')
|
||
}
|
||
|
||
const startExam = async (id: number) => {
|
||
setStartingId(id)
|
||
try {
|
||
const exam = await examApi.getExam(id)
|
||
onLoadExam(exam, false, id)
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
setStartingId(null)
|
||
}
|
||
}
|
||
|
||
const handleShare = async (id: number) => {
|
||
setSharingId(id)
|
||
try {
|
||
const res = await examApi.shareExam(id)
|
||
const url = `${window.location.origin}/share/${res.shareToken}`
|
||
await navigator.clipboard.writeText(url)
|
||
closeMenu()
|
||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setSharingId(null)
|
||
}
|
||
}
|
||
|
||
const handleUnshare = async (id: number) => {
|
||
try {
|
||
await examApi.unshareExam(id)
|
||
closeMenu()
|
||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
}
|
||
}
|
||
|
||
const handleFavorite = async (id: number, isFavorite: boolean) => {
|
||
try {
|
||
await examApi.setFavorite(id, isFavorite)
|
||
setExams((prev) => prev.map((e) => {
|
||
if (e.id !== id) return e
|
||
const updated = { ...e, is_favorite: isFavorite ? 1 : 0 }
|
||
return updated
|
||
}).sort((a, b) => (b.is_favorite - a.is_favorite) || (b.updated_at - a.updated_at)))
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
}
|
||
}
|
||
|
||
const handleExport = async (id: number, title: string) => {
|
||
setExportingId(id)
|
||
try {
|
||
const blob = await examApi.exportExam(id)
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setExportingId(null)
|
||
}
|
||
}
|
||
|
||
const handleDuplicate = async (id: number) => {
|
||
setDuplicatingId(id)
|
||
try {
|
||
await examApi.duplicateExam(id)
|
||
void loadData()
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setDuplicatingId(null)
|
||
}
|
||
}
|
||
|
||
const handleCreateCollection = async () => {
|
||
const name = newCollectionName.trim()
|
||
if (!name) return
|
||
setCreatingCollection(true)
|
||
try {
|
||
await collectionApi.createCollection(name)
|
||
setNewCollectionName('')
|
||
setShowNewCollection(false)
|
||
void loadData()
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setCreatingCollection(false)
|
||
}
|
||
}
|
||
|
||
const handleMoveExam = async (examId: number, collectionId: number | null) => {
|
||
setMovingExamId(examId)
|
||
try {
|
||
await collectionApi.moveExamToCollection(examId, collectionId)
|
||
closeMenu()
|
||
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
|
||
setCollections((prev) => prev.map((c) => {
|
||
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
|
||
const oldExam = exams.find((e) => e.id === examId)
|
||
if (oldExam?.collection_id !== null && c.id === oldExam?.collection_id) return { ...c, exam_count: Math.max(0, c.exam_count - 1) }
|
||
return c
|
||
}))
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setMovingExamId(null)
|
||
}
|
||
}
|
||
|
||
const handleRenameCollection = async (id: number) => {
|
||
const name = editingCollectionName.trim()
|
||
if (!name) {
|
||
setEditingCollectionId(null)
|
||
return
|
||
}
|
||
try {
|
||
await collectionApi.updateCollection(id, name)
|
||
setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name } : c))
|
||
setEditingCollectionId(null)
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
}
|
||
}
|
||
|
||
const handleDeleteCollection = async (id: number) => {
|
||
try {
|
||
await collectionApi.deleteCollection(id)
|
||
setDeleteCollectionId(null)
|
||
setExams((prev) => prev.map((e) => e.collection_id === id ? { ...e, collection_id: null } : e))
|
||
setCollections((prev) => prev.filter((c) => c.id !== id))
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
}
|
||
}
|
||
|
||
const toggleSelect = (id: number) => {
|
||
setSelectedIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const selectAllFiltered = () => {
|
||
setSelectedIds(new Set(filteredExams.map((e) => e.id)))
|
||
}
|
||
|
||
const clearSelection = () => {
|
||
setSelectedIds(new Set())
|
||
}
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (selectedIds.size === 0) return
|
||
if (!window.confirm(t('workspace.batchDeleteConfirm'))) return
|
||
setBatchDeleting(true)
|
||
try {
|
||
await examApi.batchDelete(Array.from(selectedIds))
|
||
setSelectedIds(new Set())
|
||
void loadData()
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setBatchDeleting(false)
|
||
}
|
||
}
|
||
|
||
const handleBatchExport = async () => {
|
||
if (selectedIds.size === 0) return
|
||
setBatchExporting(true)
|
||
try {
|
||
for (const id of selectedIds) {
|
||
const exam = exams.find((e) => e.id === id)
|
||
if (exam) {
|
||
const blob = await examApi.exportExam(id)
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${exam.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setBatchExporting(false)
|
||
}
|
||
}
|
||
|
||
const handleBatchMove = async (collectionId: number | null) => {
|
||
if (selectedIds.size === 0) return
|
||
setBatchMoving(true)
|
||
try {
|
||
for (const id of selectedIds) {
|
||
await collectionApi.moveExamToCollection(id, collectionId)
|
||
}
|
||
setBatchMoveOpen(false)
|
||
setSelectedIds(new Set())
|
||
void loadData()
|
||
} catch (e) {
|
||
setError((e as Error).message)
|
||
} finally {
|
||
setBatchMoving(false)
|
||
}
|
||
}
|
||
|
||
const formatDate = (ts: number) => {
|
||
const d = new Date(ts * 1000)
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
}
|
||
|
||
const parseTags = (tagsJson: string): string[] => {
|
||
try {
|
||
const parsed = JSON.parse(tagsJson)
|
||
return Array.isArray(parsed) ? parsed : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
const filteredExams = exams.filter((exam) => {
|
||
if (!searchQuery.trim()) return true
|
||
const q = searchQuery.toLowerCase()
|
||
const tags = parseTags(exam.tags)
|
||
return (
|
||
exam.title.toLowerCase().includes(q) ||
|
||
(exam.subtitle?.toLowerCase().includes(q) ?? false) ||
|
||
tags.some((t) => t.toLowerCase().includes(q))
|
||
)
|
||
}).filter((exam) => {
|
||
if (activeFilter === 'all') return true
|
||
if (activeFilter === 'uncategorized') return exam.collection_id === null
|
||
return exam.collection_id === activeFilter
|
||
})
|
||
|
||
const uncategorizedCount = exams.filter((e) => e.collection_id === null).length
|
||
|
||
return (
|
||
<div className="workspace-section fade-in" style={{ display: 'flex', gap: 20 }}>
|
||
{/* Collections Sidebar */}
|
||
<div style={{ width: 220, flexShrink: 0 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--ink-3)', marginBottom: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
{t('workspace.collections')}
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||
onClick={() => setShowNewCollection(true)}
|
||
title={t('workspace.newCollection')}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
{showNewCollection && (
|
||
<div style={{ marginBottom: 8, display: 'flex', gap: 4 }}>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder={t('workspace.collectionNamePlaceholder')}
|
||
value={newCollectionName}
|
||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }}
|
||
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
|
||
autoFocus
|
||
/>
|
||
<button className="btn btn-sm btn-primary" onClick={() => void handleCreateCollection()} disabled={creatingCollection} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||
{t('workspace.create')}
|
||
</button>
|
||
<button className="btn btn-sm btn-ghost" onClick={() => { setShowNewCollection(false); setNewCollectionName('') }} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||
{t('common.cancel')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<button
|
||
className={`btn btn-ghost btn-sm${activeFilter === 'all' ? ' active' : ''}`}
|
||
onClick={() => setActiveFilter('all')}
|
||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'all' ? 600 : 400 }}
|
||
>
|
||
<span>{t('workspace.allExams')}</span>
|
||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{exams.length}</span>
|
||
</button>
|
||
<button
|
||
className={`btn btn-ghost btn-sm${activeFilter === 'uncategorized' ? ' active' : ''}`}
|
||
onClick={() => setActiveFilter('uncategorized')}
|
||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'uncategorized' ? 600 : 400 }}
|
||
>
|
||
<span>{t('workspace.uncategorized')}</span>
|
||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{uncategorizedCount}</span>
|
||
</button>
|
||
{collections.map((col) => (
|
||
<div key={col.id} style={{ position: 'relative' }}>
|
||
{editingCollectionId === col.id ? (
|
||
<div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
value={editingCollectionName}
|
||
onChange={(e) => setEditingCollectionName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') void handleRenameCollection(col.id)
|
||
if (e.key === 'Escape') setEditingCollectionId(null)
|
||
}}
|
||
onBlur={() => void handleRenameCollection(col.id)}
|
||
style={{ fontSize: 12, padding: '3px 6px', flex: 1 }}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className={`btn btn-ghost btn-sm${activeFilter === col.id ? ' active' : ''}`}
|
||
onClick={() => setActiveFilter(col.id)}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault()
|
||
setEditingCollectionName(col.name)
|
||
setEditingCollectionId(col.id)
|
||
}}
|
||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === col.id ? 600 : 400 }}
|
||
>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{col.name}</span>
|
||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>{col.exam_count}</span>
|
||
</button>
|
||
)}
|
||
{activeFilter === col.id && editingCollectionId !== col.id && (
|
||
<div style={{ display: 'flex', gap: 4, padding: '0 8px 4px', marginTop: -2 }}>
|
||
<button className="btn btn-ghost btn-sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => { setEditingCollectionName(col.name); setEditingCollectionId(col.id) }}>
|
||
{t('workspace.rename')}
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm admin-btn-danger-ghost" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setDeleteCollectionId(col.id)}>
|
||
{t('common.delete')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{deleteCollectionId === col.id && (
|
||
<div style={{ padding: '4px 8px', fontSize: 11, background: 'var(--wrong-weak)', borderRadius: 6, margin: '2px 0' }}>
|
||
<div style={{ marginBottom: 4 }}>{t('workspace.deleteCollectionConfirm')}</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button className="btn btn-sm admin-btn-danger" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => void handleDeleteCollection(col.id)}>
|
||
{t('common.confirm')}
|
||
</button>
|
||
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
|
||
{t('common.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Exams List */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="workspace-section-header">
|
||
<div>
|
||
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
|
||
</div>
|
||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||
+ {t('workspace.newExam')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
placeholder={t('workspace.searchExams')}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
style={{ maxWidth: 400 }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Batch toolbar */}
|
||
{!loading && filteredExams.length > 0 && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', userSelect: 'none' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.size > 0 && selectedIds.size === filteredExams.length}
|
||
onChange={() => {
|
||
if (selectedIds.size === filteredExams.length) clearSelection()
|
||
else selectAllFiltered()
|
||
}}
|
||
ref={(el) => {
|
||
if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < filteredExams.length
|
||
}}
|
||
/>
|
||
{selectedIds.size === 0 ? t('workspace.batchSelectAll') : t('workspace.batchSelected', { count: selectedIds.size })}
|
||
</label>
|
||
{selectedIds.size > 0 && (
|
||
<>
|
||
<div style={{ width: 1, height: 16, background: 'var(--line-2)' }} />
|
||
<button
|
||
className="btn btn-sm admin-btn-danger-ghost"
|
||
onClick={() => void handleBatchDelete()}
|
||
disabled={batchDeleting}
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
{batchDeleting ? t('common.loading') : `${t('common.delete')} (${selectedIds.size})`}
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-ghost"
|
||
onClick={() => void handleBatchExport()}
|
||
disabled={batchExporting}
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
{batchExporting ? t('common.loading') : `${t('workspace.export')} (${selectedIds.size})`}
|
||
</button>
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
className="btn btn-sm btn-ghost"
|
||
onClick={() => setBatchMoveOpen((v) => !v)}
|
||
disabled={batchMoving}
|
||
style={{ fontSize: 12 }}
|
||
>
|
||
{batchMoving ? t('common.loading') : `${t('workspace.moveTo')} (${selectedIds.size})`}
|
||
</button>
|
||
{batchMoveOpen && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 'calc(100% + 4px)',
|
||
zIndex: 10,
|
||
minWidth: 180,
|
||
background: 'var(--paper)',
|
||
border: '1px solid var(--line-2)',
|
||
borderRadius: 8,
|
||
boxShadow: 'var(--shadow-md)',
|
||
padding: '6px 0',
|
||
}}
|
||
>
|
||
<button
|
||
className="user-menu-item"
|
||
style={{ fontSize: 13 }}
|
||
onClick={() => void handleBatchMove(null)}
|
||
>
|
||
{t('workspace.uncategorized')}
|
||
</button>
|
||
{collections.map((col) => (
|
||
<button
|
||
key={col.id}
|
||
className="user-menu-item"
|
||
style={{ fontSize: 13 }}
|
||
onClick={() => void handleBatchMove(col.id)}
|
||
>
|
||
{col.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</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">{t('common.loading')}</div>
|
||
) : filteredExams.length === 0 ? (
|
||
<div className="workspace-empty">
|
||
<div className="workspace-empty-icon">◇</div>
|
||
<div className="workspace-empty-title">
|
||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
|
||
</div>
|
||
<div className="workspace-empty-desc">
|
||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
|
||
</div>
|
||
{!searchQuery && activeFilter === 'all' && (
|
||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||
{t('workspace.createFirstExam')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="workspace-list">
|
||
{filteredExams.map((exam) => {
|
||
const tags = parseTags(exam.tags)
|
||
return (
|
||
<div key={exam.id} className={`workspace-card${exam.is_favorite ? ' workspace-card-favorite' : ''}`}>
|
||
<div className="workspace-card-body">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.has(exam.id)}
|
||
onChange={() => toggleSelect(exam.id)}
|
||
style={{ cursor: 'pointer' }}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => void handleFavorite(exam.id, !exam.is_favorite)}
|
||
title={exam.is_favorite ? t('workspace.unpin') : t('workspace.pin')}
|
||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||
>
|
||
{exam.is_favorite ? '★' : '☆'}
|
||
</button>
|
||
<div className="workspace-card-title">{exam.title}</div>
|
||
</div>
|
||
{exam.subtitle && (
|
||
<div className="workspace-card-sub">{exam.subtitle}</div>
|
||
)}
|
||
<div className="workspace-card-meta">
|
||
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
|
||
<span>·</span>
|
||
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
|
||
</div>
|
||
{tags.length > 0 && (
|
||
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
|
||
{tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
style={{
|
||
fontSize: 11,
|
||
padding: '2px 8px',
|
||
borderRadius: 4,
|
||
background: 'var(--paper)',
|
||
color: 'var(--ink-3)',
|
||
border: '1px solid var(--line-2)',
|
||
}}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="workspace-card-actions">
|
||
{deleteConfirm === exam.id ? (
|
||
<div className="workspace-delete-confirm">
|
||
<span style={{ fontSize: 13, color: 'var(--ink-3)', marginRight: 4 }}>
|
||
{t('workspace.batchDeleteConfirm')}
|
||
</span>
|
||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||
{t('common.confirm')}
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||
{t('common.cancel')}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => void startExam(exam.id)}
|
||
disabled={startingId === exam.id}
|
||
>
|
||
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
|
||
</button>
|
||
<button
|
||
className="btn btn-sm"
|
||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||
disabled={startingId === exam.id}
|
||
>
|
||
{t('common.edit')}
|
||
</button>
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
className="btn btn-ghost btn-sm workspace-menu-trigger"
|
||
onClick={() => menuOpenId === exam.id ? closeMenu() : openMenu(exam.id)}
|
||
disabled={startingId === exam.id}
|
||
title="More actions"
|
||
aria-label="More actions"
|
||
aria-haspopup="menu"
|
||
aria-expanded={menuOpenId === exam.id}
|
||
>
|
||
⋯
|
||
</button>
|
||
{menuOpenId === exam.id && (
|
||
<>
|
||
<button
|
||
className="workspace-menu-backdrop"
|
||
onClick={closeMenu}
|
||
aria-label="Close menu"
|
||
tabIndex={-1}
|
||
/>
|
||
<div className="workspace-menu" role="menu">
|
||
{menuSection === 'main' && (
|
||
<>
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => { closeMenu(); void handleDuplicate(exam.id) }}
|
||
disabled={duplicatingId === exam.id}
|
||
>
|
||
<span className="workspace-menu-icon">⧉</span>
|
||
<span>{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}</span>
|
||
</button>
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => { closeMenu(); void handleExport(exam.id, exam.title) }}
|
||
disabled={exportingId === exam.id}
|
||
>
|
||
<span className="workspace-menu-icon">↓</span>
|
||
<span>{exportingId === exam.id ? t('common.loading') : t('workspace.export')}</span>
|
||
</button>
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => setMenuSection('move')}
|
||
disabled={movingExamId === exam.id}
|
||
>
|
||
<span className="workspace-menu-icon">▤</span>
|
||
<span>{t('workspace.moveTo')}</span>
|
||
<span className="workspace-menu-chevron">›</span>
|
||
</button>
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => setMenuSection('share')}
|
||
disabled={sharingId === exam.id}
|
||
>
|
||
<span className="workspace-menu-icon">↗</span>
|
||
<span>
|
||
{sharingId === exam.id
|
||
? t('common.loading')
|
||
: (exam.share_token ? t('share.shared') : t('share.share'))}
|
||
</span>
|
||
<span className="workspace-menu-chevron">›</span>
|
||
</button>
|
||
<div className="workspace-menu-divider" />
|
||
<button
|
||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||
onClick={() => { closeMenu(); setDeleteConfirm(exam.id) }}
|
||
>
|
||
<span className="workspace-menu-icon">✕</span>
|
||
<span>{t('common.delete')}</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
{menuSection === 'move' && (
|
||
<>
|
||
<button
|
||
className="user-menu-item workspace-menu-back"
|
||
onClick={() => setMenuSection('main')}
|
||
>
|
||
‹ {t('common.back')}
|
||
</button>
|
||
<div className="workspace-menu-divider" />
|
||
{exam.collection_id !== null && (
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => void handleMoveExam(exam.id, null)}
|
||
>
|
||
{t('workspace.removeFromCollection')}
|
||
</button>
|
||
)}
|
||
{collections.map((col) => (
|
||
col.id !== exam.collection_id && (
|
||
<button
|
||
key={col.id}
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => void handleMoveExam(exam.id, col.id)}
|
||
>
|
||
{col.name}
|
||
</button>
|
||
)
|
||
))}
|
||
{collections.length === 0 && exam.collection_id === null && (
|
||
<div className="user-menu-item workspace-menu-item" style={{ color: 'var(--ink-3)', cursor: 'default' }}>
|
||
{t('workspace.collections')}: 0
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{menuSection === 'share' && (
|
||
<>
|
||
<button
|
||
className="user-menu-item workspace-menu-back"
|
||
onClick={() => setMenuSection('main')}
|
||
>
|
||
‹ {t('common.back')}
|
||
</button>
|
||
<div className="workspace-menu-divider" />
|
||
{exam.share_token ? (
|
||
<>
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => {
|
||
const url = `${window.location.origin}/share/${exam.share_token}`
|
||
void navigator.clipboard.writeText(url)
|
||
closeMenu()
|
||
}}
|
||
>
|
||
{t('share.copyLink')}
|
||
</button>
|
||
<button
|
||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||
onClick={() => void handleUnshare(exam.id)}
|
||
>
|
||
{t('share.revokeLink')}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
className="user-menu-item workspace-menu-item"
|
||
onClick={() => void handleShare(exam.id)}
|
||
>
|
||
{sharingId === exam.id ? t('common.loading') : t('share.generateLink')}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|