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:
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{serverID}", h.Get)
|
r.Get("/{serverID}", h.Get)
|
||||||
r.Patch("/{serverID}", h.Update)
|
r.Patch("/{serverID}", h.Update)
|
||||||
r.Delete("/{serverID}", h.Delete)
|
r.Delete("/{serverID}", h.Delete)
|
||||||
|
r.Delete("/{serverID}/members/me", h.LeaveServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
type createServerRequest struct {
|
type createServerRequest struct {
|
||||||
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LeaveServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID := chi.URLParam(r, "serverID")
|
||||||
|
|
||||||
|
// Cannot leave a server you own
|
||||||
|
var ownerID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerID == userID {
|
||||||
|
http.Error(w, `{"error":"server owner cannot leave; transfer ownership or delete the server"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE user_id = $1 AND server_id = $2`, userID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to leave server"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
http.Error(w, `{"error":"not a member"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx';
|
|||||||
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||||
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
||||||
import { useReadStatesStore } from '../stores/readStates.ts';
|
import { useReadStatesStore } from '../stores/readStates.ts';
|
||||||
|
import { useContextMenu } from './ContextMenu.tsx';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
type NotifLevel = 'all' | 'mentions' | 'none';
|
type NotifLevel = 'all' | 'mentions' | 'none';
|
||||||
@@ -59,12 +60,21 @@ export function ChannelList() {
|
|||||||
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
||||||
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
||||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||||
|
const removeChannel = useChannelStore((state) => state.removeChannel);
|
||||||
|
const updateChannel = useChannelStore((state) => state.updateChannel);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [createInGroup, setCreateInGroup] = useState<string | null>(null);
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||||
const [toggleTick, setToggleTick] = useState(0);
|
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 notifSettings = useNotificationSettingsStore((state) => state.settings);
|
||||||
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
||||||
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
||||||
@@ -72,12 +82,20 @@ export function ChannelList() {
|
|||||||
const readStates = useReadStatesStore((state) => state.states);
|
const readStates = useReadStatesStore((state) => state.states);
|
||||||
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
||||||
|
|
||||||
useEffect(() => {
|
const { showMenu, MenuPortal } = useContextMenu();
|
||||||
|
|
||||||
|
const refreshGroups = () => {
|
||||||
if (activeServerId) {
|
if (activeServerId) {
|
||||||
fetchChannels(activeServerId);
|
|
||||||
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
|
||||||
.then(setGroups)
|
.then(setGroups)
|
||||||
.catch(() => setGroups([]));
|
.catch(() => setGroups([]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeServerId) {
|
||||||
|
fetchChannels(activeServerId);
|
||||||
|
refreshGroups();
|
||||||
} else {
|
} else {
|
||||||
setGroups([]);
|
setGroups([]);
|
||||||
}
|
}
|
||||||
@@ -157,6 +175,132 @@ export function ChannelList() {
|
|||||||
return !(channelId in readStates);
|
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 (
|
return (
|
||||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
<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">
|
<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]
|
[INV]
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
|
||||||
className="terminal-button text-xs"
|
className="terminal-button text-xs"
|
||||||
title="Create channel"
|
title="Create channel"
|
||||||
>
|
>
|
||||||
@@ -188,61 +332,33 @@ export function ChannelList() {
|
|||||||
if (section.type === 'group') {
|
if (section.type === 'group') {
|
||||||
const grp = section.group;
|
const grp = section.group;
|
||||||
const collapsed = isCollapsed(activeServerId || '', grp.id);
|
const collapsed = isCollapsed(activeServerId || '', grp.id);
|
||||||
|
const isEditingGroup = editingGroupId === grp.id;
|
||||||
return (
|
return (
|
||||||
<div key={'grp:' + grp.id} className="mb-3">
|
<div key={'grp:' + grp.id} className="mb-3">
|
||||||
<div
|
<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); }}
|
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>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||||
{section.channels.map((channel) =>
|
{section.channels.map((channel) => renderChannel(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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,56 +369,17 @@ export function ChannelList() {
|
|||||||
<div key={'cat:' + section.name} className="mb-3">
|
<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-t text-xs uppercase mb-1">{section.name}</div>
|
||||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||||
{section.channels.map((channel) =>
|
{section.channels.map((channel) => renderChannel(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>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{showCreate && activeServerId && (
|
{showCreate && activeServerId && (
|
||||||
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
<CreateChannelModal
|
||||||
|
serverId={activeServerId}
|
||||||
|
defaultGroupId={createInGroup}
|
||||||
|
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{showInvite && activeServerId && activeServer && (
|
{showInvite && activeServerId && activeServer && (
|
||||||
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
||||||
@@ -315,6 +392,7 @@ export function ChannelList() {
|
|||||||
onClose={() => setSettingsChannel(null)}
|
onClose={() => setSettingsChannel(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{MenuPortal}
|
||||||
</div>
|
</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 {
|
interface CreateChannelModalProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
defaultGroupId?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +37,13 @@ const SLOWMODE_OPTIONS = [
|
|||||||
{ label: '5 minutes', value: 300 },
|
{ label: '5 minutes', value: 300 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
|
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
|
||||||
const [category, setCategory] = useState('general');
|
const [category, setCategory] = useState('general');
|
||||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||||
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
const [groups, setGroups] = useState<ServerGroup[]>([]);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState('');
|
const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || '');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useChannelStore } from '../stores/channel.ts';
|
|||||||
import { useLayoutStore } from '../stores/layout.ts';
|
import { useLayoutStore } from '../stores/layout.ts';
|
||||||
import { CreateServerModal } from './CreateServerModal.tsx';
|
import { CreateServerModal } from './CreateServerModal.tsx';
|
||||||
import { JoinServerModal } from './JoinServerModal.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 {
|
function getInitials(name: string): string {
|
||||||
const words = name.trim().split(/\s+/);
|
const words = name.trim().split(/\s+/);
|
||||||
@@ -22,6 +26,10 @@ export function ServerBar() {
|
|||||||
const { isDM, setDM } = useLayoutStore();
|
const { isDM, setDM } = useLayoutStore();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showJoin, setShowJoin] = 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(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
@@ -39,6 +47,26 @@ export function ServerBar() {
|
|||||||
setDM(true);
|
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 (
|
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">
|
<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
|
<button
|
||||||
key={server.id}
|
key={server.id}
|
||||||
onClick={() => handleSelect(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 ' +
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||||
(server.id === activeServerId && !isDM
|
(server.id === activeServerId && !isDM
|
||||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||||
@@ -89,6 +118,13 @@ export function ServerBar() {
|
|||||||
</div>
|
</div>
|
||||||
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
|
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
|
||||||
{showJoin && <JoinServerModal onClose={() => setShowJoin(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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user