feat(admin): add context menu for user actions with dynamic positioning
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m31s

This commit is contained in:
2026-03-01 14:18:21 +01:00
parent bfec8de195
commit fae46c8395
3 changed files with 63 additions and 31 deletions

View File

@@ -230,6 +230,7 @@
"presentationRemoveFailed": "Präsentation konnte nicht entfernt werden", "presentationRemoveFailed": "Präsentation konnte nicht entfernt werden",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Aktuell:", "presentationCurrent": "Aktuell:",
"shareTitle": "Raum teilen",
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.", "shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...", "shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
"shareAdded": "Benutzer hinzugef\u00fcgt", "shareAdded": "Benutzer hinzugef\u00fcgt",

View File

@@ -230,6 +230,7 @@
"presentationRemoveFailed": "Could not remove presentation", "presentationRemoveFailed": "Could not remove presentation",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB", "presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Current:", "presentationCurrent": "Current:",
"shareTitle": "Share Room",
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.", "shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
"shareSearchPlaceholder": "Search users (name or email)...", "shareSearchPlaceholder": "Search users (name or email)...",
"shareAdded": "User added", "shareAdded": "User added",

View File

@@ -27,6 +27,8 @@ export default function Admin() {
const [showCreateUser, setShowCreateUser] = useState(false); const [showCreateUser, setShowCreateUser] = useState(false);
const [creatingUser, setCreatingUser] = useState(false); const [creatingUser, setCreatingUser] = useState(false);
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' }); const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
const menuBtnRefs = useRef({});
const [menuPos, setMenuPos] = useState(null);
// Invite state // Invite state
const [invites, setInvites] = useState([]); const [invites, setInvites] = useState([]);
@@ -88,6 +90,7 @@ export default function Admin() {
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed')); toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
} }
setOpenMenu(null); setOpenMenu(null);
setMenuPos(null);
}; };
const handleDelete = async (userId, userName) => { const handleDelete = async (userId, userName) => {
@@ -100,6 +103,7 @@ export default function Admin() {
toast.error(err.response?.data?.error || t('admin.userDeleteFailed')); toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
} }
setOpenMenu(null); setOpenMenu(null);
setMenuPos(null);
}; };
const handleResetPassword = async (e) => { const handleResetPassword = async (e) => {
@@ -580,43 +584,32 @@ export default function Admin() {
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')} {new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex items-center justify-end relative"> <div className="flex items-center justify-end">
<button <button
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)} ref={el => { menuBtnRefs.current[u.id] = el; }}
onClick={() => {
if (openMenu === u.id) {
setOpenMenu(null);
setMenuPos(null);
} else {
const rect = menuBtnRefs.current[u.id]?.getBoundingClientRect();
if (rect) {
const menuHeight = 130;
const spaceAbove = rect.top;
if (spaceAbove >= menuHeight) {
setMenuPos({ top: rect.top - menuHeight - 4, left: rect.right - 192 });
} else {
setMenuPos({ top: rect.bottom + 4, left: rect.right - 192 });
}
}
setOpenMenu(u.id);
}
}}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors" className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
disabled={u.id === user.id} disabled={u.id === user.id}
> >
<MoreVertical size={16} /> <MoreVertical size={16} />
</button> </button>
{openMenu === u.id && u.id !== user.id && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
<div className="absolute right-0 bottom-full mb-1 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<button
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
</button>
<button
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
<Key size={14} />
{t('admin.resetPassword')}
</button>
<button
onClick={() => handleDelete(u.id, u.name)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
>
<Trash2 size={14} />
{t('admin.deleteUser')}
</button>
</div>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -633,6 +626,43 @@ export default function Admin() {
)} )}
</div> </div>
{/* Context menu portal */}
{openMenu && menuPos && openMenu !== user.id && (() => {
const u = users.find(u => u.id === openMenu);
if (!u) return null;
return (
<>
<div className="fixed inset-0 z-40" onClick={() => { setOpenMenu(null); setMenuPos(null); }} />
<div
className="fixed z-50 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden"
style={{ top: menuPos.top, left: menuPos.left }}
>
<button
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
</button>
<button
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); setMenuPos(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
<Key size={14} />
{t('admin.resetPassword')}
</button>
<button
onClick={() => { handleDelete(u.id, u.name); setMenuPos(null); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
>
<Trash2 size={14} />
{t('admin.deleteUser')}
</button>
</div>
</>
);
})()}
{/* Reset password modal */} {/* Reset password modal */}
{resetPwModal && ( {resetPwModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">