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:
@@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx';
|
||||
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
||||
import { useReadStatesStore } from '../stores/readStates.ts';
|
||||
import { useContextMenu } from './ContextMenu.tsx';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
type NotifLevel = 'all' | 'mentions' | 'none';
|
||||
@@ -59,12 +60,21 @@ export function ChannelList() {
|
||||
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
||||
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||
const removeChannel = useChannelStore((state) => state.removeChannel);
|
||||
const updateChannel = useChannelStore((state) => state.updateChannel);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createInGroup, setCreateInGroup] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [toggleTick, setToggleTick] = useState(0);
|
||||
|
||||
// Inline editing state
|
||||
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
||||
const [editingChannelName, setEditingChannelName] = useState('');
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editingGroupName, setEditingGroupName] = useState('');
|
||||
|
||||
const notifSettings = useNotificationSettingsStore((state) => state.settings);
|
||||
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
||||
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
||||
@@ -72,12 +82,20 @@ export function ChannelList() {
|
||||
const readStates = useReadStatesStore((state) => state.states);
|
||||
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
||||
|
||||
useEffect(() => {
|
||||
const { showMenu, MenuPortal } = useContextMenu();
|
||||
|
||||
const refreshGroups = () => {
|
||||
if (activeServerId) {
|
||||
fetchChannels(activeServerId);
|
||||
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
||||
.then(setGroups)
|
||||
.catch(() => setGroups([]));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
fetchChannels(activeServerId);
|
||||
refreshGroups();
|
||||
} else {
|
||||
setGroups([]);
|
||||
}
|
||||
@@ -157,6 +175,132 @@ export function ChannelList() {
|
||||
return !(channelId in readStates);
|
||||
};
|
||||
|
||||
// --- Channel context menu ---
|
||||
const handleChannelContextMenu = (e: React.MouseEvent, channel: { id: string; name: string }) => {
|
||||
showMenu(e, [
|
||||
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditChannel(channel) },
|
||||
{ label: 'Permissions', icon: '🔒', onClick: () => setSettingsChannel({ id: channel.id, name: channel.name }) },
|
||||
{ label: 'Delete Channel', icon: '🗑️', danger: true, onClick: () => deleteChannel(channel.id, channel.name) },
|
||||
]);
|
||||
};
|
||||
|
||||
const startEditChannel = (channel: { id: string; name: string }) => {
|
||||
setEditingChannelId(channel.id);
|
||||
setEditingChannelName(channel.name);
|
||||
};
|
||||
|
||||
const commitEditChannel = async () => {
|
||||
if (!editingChannelId || !editingChannelName.trim()) { setEditingChannelId(null); return; }
|
||||
try {
|
||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
||||
`/servers/${activeServerId}/channels/${editingChannelId}`,
|
||||
{ name: editingChannelName.trim() }
|
||||
);
|
||||
updateChannel(updated as any);
|
||||
} catch { /* ignore */ }
|
||||
setEditingChannelId(null);
|
||||
};
|
||||
|
||||
const deleteChannel = async (channelId: string, channelName: string) => {
|
||||
if (!confirm(`Delete #${channelName}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api.delete(`/servers/${activeServerId}/channels/${channelId}`);
|
||||
removeChannel(channelId);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// --- Group context menu ---
|
||||
const handleGroupContextMenu = (e: React.MouseEvent, group: ServerGroup) => {
|
||||
showMenu(e, [
|
||||
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditGroup(group) },
|
||||
{ label: 'Create Channel Here', icon: '➕', onClick: () => { setCreateInGroup(group.id); setShowCreate(true); } },
|
||||
{ label: 'Delete Group', icon: '🗑️', danger: true, onClick: () => deleteGroup(group.id, group.name) },
|
||||
]);
|
||||
};
|
||||
|
||||
const startEditGroup = (group: ServerGroup) => {
|
||||
setEditingGroupId(group.id);
|
||||
setEditingGroupName(group.name);
|
||||
};
|
||||
|
||||
const commitEditGroup = async () => {
|
||||
if (!editingGroupId || !editingGroupName.trim()) { setEditingGroupId(null); return; }
|
||||
try {
|
||||
await api.patch(`/servers/${activeServerId}/groups/${editingGroupId}`, { name: editingGroupName.trim() });
|
||||
refreshGroups();
|
||||
} catch { /* ignore */ }
|
||||
setEditingGroupId(null);
|
||||
};
|
||||
|
||||
const deleteGroup = async (groupId: string, groupName: string) => {
|
||||
if (!confirm(`Delete group "${groupName}"? Channels will be moved to ungrouped.`)) return;
|
||||
try {
|
||||
await api.delete(`/servers/${activeServerId}/groups/${groupId}`);
|
||||
refreshGroups();
|
||||
if (activeServerId) fetchChannels(activeServerId);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// --- Channel row renderer ---
|
||||
const renderChannel = (channel: typeof channels[0]) => {
|
||||
if (channel.type === 'voice') {
|
||||
return (
|
||||
<VoiceChannel key={channel.id} channelId={channel.id} channelName={channel.name} />
|
||||
);
|
||||
}
|
||||
|
||||
const isEditing = editingChannelId === channel.id;
|
||||
|
||||
return (
|
||||
<div key={channel.id} className="group" onContextMenu={(e) => handleChannelContextMenu(e, channel)}>
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingChannelName}
|
||||
onChange={(e) => setEditingChannelName(e.target.value)}
|
||||
onBlur={commitEditChannel}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEditChannel(); if (e.key === 'Escape') setEditingChannelId(null); }}
|
||||
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-sm outline-none border border-gb-orange"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
)}
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
|
||||
@@ -171,7 +315,7 @@ export function ChannelList() {
|
||||
[INV]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
|
||||
className="terminal-button text-xs"
|
||||
title="Create channel"
|
||||
>
|
||||
@@ -188,61 +332,33 @@ export function ChannelList() {
|
||||
if (section.type === 'group') {
|
||||
const grp = section.group;
|
||||
const collapsed = isCollapsed(activeServerId || '', grp.id);
|
||||
const isEditingGroup = editingGroupId === grp.id;
|
||||
return (
|
||||
<div key={'grp:' + grp.id} className="mb-3">
|
||||
<div
|
||||
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg"
|
||||
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg flex items-center"
|
||||
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
|
||||
onContextMenu={(e) => handleGroupContextMenu(e, grp)}
|
||||
>
|
||||
{collapsed ? '[+]' : '[-]'} {grp.name}
|
||||
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
|
||||
{isEditingGroup ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingGroupName}
|
||||
onChange={(e) => setEditingGroupName(e.target.value)}
|
||||
onBlur={commitEditGroup}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEditGroup(); if (e.key === 'Escape') setEditingGroupId(null); }}
|
||||
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span>{grp.name}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{section.channels.map((channel) => renderChannel(channel))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -253,56 +369,17 @@ export function ChannelList() {
|
||||
<div key={'cat:' + section.name} className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">{section.name}</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{section.channels.map((channel) =>
|
||||
channel.type === 'voice' ? (
|
||||
<VoiceChannel
|
||||
key={channel.id}
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
/>
|
||||
) : (
|
||||
<div key={channel.id} className="group">
|
||||
<button
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">
|
||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{channel.name}</span>
|
||||
{hasUnread(channel.id) && (
|
||||
<span className="text-gb-orange text-xs font-bold" title="Unread messages">●</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||||
>
|
||||
{notifIcon(channel.id)}
|
||||
</button>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||
title="Channel settings"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
[⚙]
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{section.channels.map((channel) => renderChannel(channel))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showCreate && activeServerId && (
|
||||
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
||||
<CreateChannelModal
|
||||
serverId={activeServerId}
|
||||
defaultGroupId={createInGroup}
|
||||
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
|
||||
/>
|
||||
)}
|
||||
{showInvite && activeServerId && activeServer && (
|
||||
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
||||
@@ -315,6 +392,7 @@ export function ChannelList() {
|
||||
onClose={() => setSettingsChannel(null)}
|
||||
/>
|
||||
)}
|
||||
{MenuPortal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
serverId: string;
|
||||
defaultGroupId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -36,13 +37,13 @@ const SLOWMODE_OPTIONS = [
|
||||
{ label: '5 minutes', value: 300 },
|
||||
];
|
||||
|
||||
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||
export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
|
||||
const [category, setCategory] = useState('general');
|
||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('');
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user