feat: right-click context menus for channels, groups, and servers
ContextMenu: reusable hook-based component that positions a menu at
the cursor and auto-closes on outside click or Escape.
ChannelList:
- Right-click channel -> Edit Name, Permissions, Delete Channel
- Right-click group header -> Edit Name, Create Channel Here, Delete Group
- Inline rename: replacing name text with an input, commits on Enter/blur
- CreateChannelModal now accepts defaultGroupId to pre-select a group
ServerBar:
- Right-click server icon -> Server Settings, Invite People, Leave Server
- Leave calls DELETE /servers/{id}/members/me
Backend:
- Added LeaveServer handler (DELETE /servers/{serverID}/members/me)
- Server owner cannot leave; must transfer or delete
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showMenu = useCallback((e: React.MouseEvent, items: ContextMenuItem[]) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Position: use cursor, but keep within viewport
|
||||
const x = Math.min(e.clientX, window.innerWidth - 200);
|
||||
const y = Math.min(e.clientY, window.innerHeight - (items.length * 32 + 16));
|
||||
|
||||
setMenu({ x, y, items });
|
||||
}, []);
|
||||
|
||||
const hideMenu = useCallback(() => setMenu(null), []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hideMenu();
|
||||
}
|
||||
};
|
||||
// Use mousedown so it fires before the click that opened a new menu
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [menu, hideMenu]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') hideMenu();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [menu, hideMenu]);
|
||||
|
||||
const MenuPortal = menu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] bg-gb-bg border border-gb-bg-t font-mono text-xs min-w-[160px] shadow-lg"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
>
|
||||
{menu.items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
hideMenu();
|
||||
if (!item.disabled) item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${
|
||||
item.disabled
|
||||
? 'text-gb-fg-f cursor-not-allowed'
|
||||
: item.danger
|
||||
? 'text-gb-red hover:bg-gb-red hover:text-gb-bg'
|
||||
: 'text-gb-fg hover:bg-gb-bg-t hover:text-gb-orange'
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="w-4 text-center">{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { showMenu, hideMenu, MenuPortal };
|
||||
}
|
||||
Reference in New Issue
Block a user