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:
2026-07-02 08:18:12 -04:00
parent ecb26cb731
commit 31cb295a24
6 changed files with 335 additions and 97 deletions
+36
View File
@@ -4,6 +4,10 @@ import { useChannelStore } from '../stores/channel.ts';
import { useLayoutStore } from '../stores/layout.ts';
import { CreateServerModal } from './CreateServerModal.tsx';
import { JoinServerModal } from './JoinServerModal.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
import { InviteModal } from './InviteModal.tsx';
import { useContextMenu } from './ContextMenu.tsx';
import { api } from '../lib/api.ts';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@@ -22,6 +26,10 @@ export function ServerBar() {
const { isDM, setDM } = useLayoutStore();
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
const [settingsServerId, setSettingsServerId] = useState<string | null>(null);
const [inviteServer, setInviteServer] = useState<{ id: string; name: string } | null>(null);
const { showMenu, MenuPortal } = useContextMenu();
useEffect(() => {
fetchServers();
@@ -39,6 +47,26 @@ export function ServerBar() {
setDM(true);
};
const handleServerContextMenu = (e: React.MouseEvent, server: { id: string; name: string }) => {
showMenu(e, [
{ label: 'Server Settings', icon: '⚙', onClick: () => setSettingsServerId(server.id) },
{ label: 'Invite People', icon: '📨', onClick: () => setInviteServer({ id: server.id, name: server.name }) },
{ label: 'Leave Server', icon: '🚪', danger: true, onClick: () => leaveServer(server.id, server.name) },
]);
};
const leaveServer = async (serverId: string, serverName: string) => {
if (!confirm(`Leave "${serverName}"? You'll need an invite to rejoin.`)) return;
try {
await api.delete(`/servers/${serverId}/members/me`);
fetchServers();
if (activeServerId === serverId) {
setActiveServer(null);
setDM(true);
}
} catch { /* ignore */ }
};
return (
<>
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
@@ -57,6 +85,7 @@ export function ServerBar() {
<button
key={server.id}
onClick={() => handleSelect(server.id)}
onContextMenu={(e) => handleServerContextMenu(e, server)}
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
(server.id === activeServerId && !isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
@@ -89,6 +118,13 @@ export function ServerBar() {
</div>
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
{showJoin && <JoinServerModal onClose={() => setShowJoin(false)} />}
{settingsServerId && (
<ServerSettingsModal serverId={settingsServerId} onClose={() => setSettingsServerId(null)} />
)}
{inviteServer && (
<InviteModal serverId={inviteServer.id} serverName={inviteServer.name} onClose={() => setInviteServer(null)} />
)}
{MenuPortal}
</>
);
}