Files
brainwave/frontend/src/components/MyExams.tsx
T
LaurensBartelds a40bfa7838
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m43s
feat: implement overflow menu for exam actions in MyExams component
2026-05-02 17:50:21 +02:00

827 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}