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) { const { t } = useTranslation() const navigate = useNavigate() const [exams, setExams] = useState([]) const [collections, setCollections] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) const [startingId, setStartingId] = useState(null) const [sharingId, setSharingId] = useState(null) const [duplicatingId, setDuplicatingId] = useState(null) const [exportingId, setExportingId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [activeFilter, setActiveFilter] = useState('all') const [showNewCollection, setShowNewCollection] = useState(false) const [newCollectionName, setNewCollectionName] = useState('') const [creatingCollection, setCreatingCollection] = useState(false) const [movingExamId, setMovingExamId] = useState(null) const [menuOpenId, setMenuOpenId] = useState(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(null) const [editingCollectionName, setEditingCollectionName] = useState('') const [deleteCollectionId, setDeleteCollectionId] = useState(null) const [selectedIds, setSelectedIds] = useState>(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 (
{/* Collections Sidebar */}
{t('workspace.collections')}
{showNewCollection && (
setNewCollectionName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} autoFocus />
)}
{collections.map((col) => (
{editingCollectionId === col.id ? (
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 />
) : ( )} {activeFilter === col.id && editingCollectionId !== col.id && (
)} {deleteCollectionId === col.id && (
{t('workspace.deleteCollectionConfirm')}
)}
))}
{/* Exams List */}
{t('workspace.myWorkspace')}

{t('workspace.myExams')}

{/* Search */}
setSearchQuery(e.target.value)} style={{ maxWidth: 400 }} />
{/* Batch toolbar */} {!loading && filteredExams.length > 0 && (
{selectedIds.size > 0 && ( <>
{batchMoveOpen && (
{collections.map((col) => ( ))}
)}
)}
)} {error && (
{error}
)} {loading ? (
{t('common.loading')}
) : filteredExams.length === 0 ? (
◇
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
{!searchQuery && activeFilter === 'all' && ( )}
) : (
{filteredExams.map((exam) => { const tags = parseTags(exam.tags) return (
toggleSelect(exam.id)} style={{ cursor: 'pointer' }} />
{exam.title}
{exam.subtitle && (
{exam.subtitle}
)}
{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')} · {t('workspace.updated')} {formatDate(exam.updated_at)}
{tags.length > 0 && (
{tags.map((tag) => ( {tag} ))}
)}
{deleteConfirm === exam.id ? (
{t('workspace.batchDeleteConfirm')}
) : ( <>
{menuOpenId === exam.id && ( <>
)} {menuSection === 'move' && ( <>
{exam.collection_id !== null && ( )} {collections.map((col) => ( col.id !== exam.collection_id && ( ) ))} {collections.length === 0 && exam.collection_id === null && (
{t('workspace.collections')}: 0
)} )} {menuSection === 'share' && ( <>
{exam.share_token ? ( <> ) : ( )} )}
)}
)}
) })}
)}
) }