feat: implement overflow menu for exam actions in MyExams component
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m43s

This commit is contained in:
LaurensBartelds committed 2026-05-02 17:50:21 +02:00
1 parent 26041cba1e
commit a40bfa7838
2 files changed
+215 -96

No files matched your search

+117 -96
View File
@@ -33,7 +33,6 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null) const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [startingId, setStartingId] = useState<number | null>(null) const [startingId, setStartingId] = useState<number | null>(null)
const [shareOpenId, setShareOpenId] = useState<number | null>(null)
const [sharingId, setSharingId] = useState<number | null>(null) const [sharingId, setSharingId] = useState<number | null>(null)
const [duplicatingId, setDuplicatingId] = useState<number | null>(null) const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
const [exportingId, setExportingId] = useState<number | null>(null) const [exportingId, setExportingId] = useState<number | null>(null)
@@ -42,8 +41,12 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
const [showNewCollection, setShowNewCollection] = useState(false) const [showNewCollection, setShowNewCollection] = useState(false)
const [newCollectionName, setNewCollectionName] = useState('') const [newCollectionName, setNewCollectionName] = useState('')
const [creatingCollection, setCreatingCollection] = useState(false) const [creatingCollection, setCreatingCollection] = useState(false)
const [moveMenuExamId, setMoveMenuExamId] = useState<number | null>(null)
const [movingExamId, setMovingExamId] = useState<number | null>(null) 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 [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
const [editingCollectionName, setEditingCollectionName] = useState('') const [editingCollectionName, setEditingCollectionName] = useState('')
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null) const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
@@ -105,7 +108,7 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
const res = await examApi.shareExam(id) const res = await examApi.shareExam(id)
const url = `${window.location.origin}/share/${res.shareToken}` const url = `${window.location.origin}/share/${res.shareToken}`
await navigator.clipboard.writeText(url) await navigator.clipboard.writeText(url)
setShareOpenId(null) closeMenu()
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e))) setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message)
@@ -117,7 +120,7 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
const handleUnshare = async (id: number) => { const handleUnshare = async (id: number) => {
try { try {
await examApi.unshareExam(id) await examApi.unshareExam(id)
setShareOpenId(null) closeMenu()
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e))) setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message)
@@ -186,7 +189,7 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
setMovingExamId(examId) setMovingExamId(examId)
try { try {
await collectionApi.moveExamToCollection(examId, collectionId) await collectionApi.moveExamToCollection(examId, collectionId)
setMoveMenuExamId(null) closeMenu()
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e)) setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
setCollections((prev) => prev.map((c) => { setCollections((prev) => prev.map((c) => {
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 } if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
@@ -633,6 +636,20 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
)} )}
</div> </div>
<div className="workspace-card-actions"> <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 <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => void startExam(exam.id)} onClick={() => void startExam(exam.id)}
@@ -647,51 +664,89 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
> >
{t('common.edit')} {t('common.edit')}
</button> </button>
<button
className="btn btn-sm"
onClick={() => void handleDuplicate(exam.id)}
disabled={duplicatingId === exam.id}
title={t('workspace.duplicate')}
>
{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}
</button>
<button
className="btn btn-sm"
onClick={() => void handleExport(exam.id, exam.title)}
disabled={exportingId === exam.id}
title={t('workspace.export')}
>
{exportingId === exam.id ? t('common.loading') : '↓'}
</button>
{/* Move to collection dropdown */}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button <button
className="btn btn-sm" className="btn btn-ghost btn-sm workspace-menu-trigger"
onClick={() => setMoveMenuExamId((id) => (id === exam.id ? null : exam.id))} onClick={() => menuOpenId === exam.id ? closeMenu() : openMenu(exam.id)}
disabled={movingExamId === exam.id} disabled={startingId === exam.id}
title={t('workspace.moveTo')} title="More actions"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menuOpenId === exam.id}
> >
{movingExamId === exam.id ? t('common.loading') : '▤'} ⋯
</button> </button>
{moveMenuExamId === exam.id && ( {menuOpenId === exam.id && (
<div <>
style={{ <button
position: 'absolute', className="workspace-menu-backdrop"
right: 0, onClick={closeMenu}
top: 'calc(100% + 4px)', aria-label="Close menu"
zIndex: 10, tabIndex={-1}
minWidth: 180, />
background: 'var(--paper)', <div className="workspace-menu" role="menu">
border: '1px solid var(--line-2)', {menuSection === 'main' && (
borderRadius: 8, <>
boxShadow: 'var(--shadow-md)', <button
padding: '6px 0', 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 && ( {exam.collection_id !== null && (
<button <button
className="user-menu-item" className="user-menu-item workspace-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleMoveExam(exam.id, null)} onClick={() => void handleMoveExam(exam.id, null)}
> >
{t('workspace.removeFromCollection')} {t('workspace.removeFromCollection')}
@@ -701,8 +756,7 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
col.id !== exam.collection_id && ( col.id !== exam.collection_id && (
<button <button
key={col.id} key={col.id}
className="user-menu-item" className="user-menu-item workspace-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleMoveExam(exam.id, col.id)} onClick={() => void handleMoveExam(exam.id, col.id)}
> >
{col.name} {col.name}
@@ -710,53 +764,35 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
) )
))} ))}
{collections.length === 0 && exam.collection_id === null && ( {collections.length === 0 && exam.collection_id === null && (
<div className="user-menu-item" style={{ fontSize: 13, color: 'var(--ink-3)', cursor: 'default' }}> <div className="user-menu-item workspace-menu-item" style={{ color: 'var(--ink-3)', cursor: 'default' }}>
{t('workspace.noSavedExams')} {t('workspace.collections')}: 0
</div> </div>
)} )}
</div> </>
)} )}
</div> {menuSection === 'share' && (
<div style={{ position: 'relative' }}> <>
<button <button
className="btn btn-sm" className="user-menu-item workspace-menu-back"
onClick={() => setShareOpenId((id) => (id === exam.id ? null : exam.id))} onClick={() => setMenuSection('main')}
disabled={startingId === exam.id || sharingId === exam.id}
title={exam.share_token ? t('share.shared') : t('share.share')}
> >
{sharingId === exam.id ? t('common.loading') : (exam.share_token ? t('share.shared') : t('share.share'))} ‹ {t('common.back')}
</button> </button>
{shareOpenId === exam.id && ( <div className="workspace-menu-divider" />
<div
style={{
position: 'absolute',
right: 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',
}}
>
{exam.share_token ? ( {exam.share_token ? (
<> <>
<button <button
className="user-menu-item" className="user-menu-item workspace-menu-item"
style={{ fontSize: 13 }}
onClick={() => { onClick={() => {
const url = `${window.location.origin}/share/${exam.share_token}` const url = `${window.location.origin}/share/${exam.share_token}`
void navigator.clipboard.writeText(url) void navigator.clipboard.writeText(url)
setShareOpenId(null) closeMenu()
}} }}
> >
{t('share.copyLink')} {t('share.copyLink')}
</button> </button>
<button <button
className="user-menu-item" className="user-menu-item workspace-menu-item workspace-menu-item-danger"
style={{ fontSize: 13, color: 'var(--wrong)' }}
onClick={() => void handleUnshare(exam.id)} onClick={() => void handleUnshare(exam.id)}
> >
{t('share.revokeLink')} {t('share.revokeLink')}
@@ -764,34 +800,19 @@ export default function MyExams({ onLoadExam }: Readonly<Props>) {
</> </>
) : ( ) : (
<button <button
className="user-menu-item" className="user-menu-item workspace-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleShare(exam.id)} onClick={() => void handleShare(exam.id)}
> >
{t('share.generateLink')} {sharingId === exam.id ? t('common.loading') : t('share.generateLink')}
</button> </button>
)} )}
</div> </>
)} )}
</div> </div>
{deleteConfirm === exam.id ? ( </>
<div className="workspace-delete-confirm"> )}
<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> </div>
) : ( </>
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title={t('common.delete')}
disabled={startingId === exam.id}
>
✕
</button>
)} )}
</div> </div>
</div> </div>
+98
View File
@@ -2532,9 +2532,107 @@ select {
.workspace-delete-confirm { .workspace-delete-confirm {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
} }
/* Overflow (⋯) menu trigger */
.workspace-menu-trigger {
font-size: 18px;
line-height: 1;
padding: 4px 10px;
letter-spacing: 0.05em;
color: var(--ink-2);
}
/* Click-away backdrop for the overflow menu */
.workspace-menu-backdrop {
position: fixed;
inset: 0;
z-index: 9;
background: transparent;
border: none;
cursor: default;
padding: 0;
}
/* Overflow menu panel */
.workspace-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 10;
min-width: 220px;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: 10px;
box-shadow: var(--shadow-md);
padding: 6px;
overflow: hidden;
animation: workspace-menu-in 120ms ease-out;
}
@keyframes workspace-menu-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.workspace-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
font-size: 13px;
border-radius: 6px;
transition: background 80ms ease, color 80ms ease;
}
.workspace-menu-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.workspace-menu-icon {
width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--ink-3);
font-size: 13px;
flex-shrink: 0;
}
.workspace-menu-chevron {
margin-left: auto;
color: var(--ink-4);
font-size: 15px;
line-height: 1;
}
.workspace-menu-divider {
height: 1px;
background: var(--line);
margin: 4px 6px;
}
.workspace-menu-item-danger {
color: var(--wrong);
}
.workspace-menu-item-danger .workspace-menu-icon {
color: var(--wrong);
}
.workspace-menu-item-danger:hover {
background: var(--wrong-weak);
color: var(--wrong);
}
.workspace-menu-back {
color: var(--ink-3);
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
}
/* Empty state */ /* Empty state */
.workspace-empty { .workspace-empty {
text-align: center; text-align: center;