diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 2d6fc30..d8462c5 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/{serverID}", h.Get) r.Patch("/{serverID}", h.Update) r.Delete("/{serverID}", h.Delete) + r.Delete("/{serverID}/members/me", h.LeaveServer) } type createServerRequest struct { @@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { 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) +} diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index 15cbeb1..f07a208 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -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(null); const [showInvite, setShowInvite] = useState(false); const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null); const [groups, setGroups] = useState([]); const [toggleTick, setToggleTick] = useState(0); + // Inline editing state + const [editingChannelId, setEditingChannelId] = useState(null); + const [editingChannelName, setEditingChannelName] = useState(''); + const [editingGroupId, setEditingGroupId] = useState(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('/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 ( + + ); + } + + const isEditing = editingChannelId === channel.id; + + return ( +
handleChannelContextMenu(e, channel)}> + + { 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} + > + [⚙] + + + +
+ ); + }; + return (
@@ -171,7 +315,7 @@ export function ChannelList() { [INV] - { 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} - > - [⚙] - - - -
- ) - )} + {section.channels.map((channel) => renderChannel(channel))} )}
@@ -253,56 +369,17 @@ export function ChannelList() {
{section.name}
---
- {section.channels.map((channel) => - channel.type === 'voice' ? ( - - ) : ( -
- - { 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} - > - [⚙] - - - -
- ) - )} + {section.channels.map((channel) => renderChannel(channel))}
); })} {showCreate && activeServerId && ( - setShowCreate(false)} /> + { setShowCreate(false); setCreateInGroup(null); }} + /> )} {showInvite && activeServerId && activeServer && ( setShowInvite(false)} /> @@ -315,6 +392,7 @@ export function ChannelList() { onClose={() => setSettingsChannel(null)} /> )} + {MenuPortal} ); } diff --git a/web/src/components/ContextMenu.tsx b/web/src/components/ContextMenu.tsx new file mode 100644 index 0000000..1c5b573 --- /dev/null +++ b/web/src/components/ContextMenu.tsx @@ -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(null); + const menuRef = useRef(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 ? ( +
+ {menu.items.map((item, i) => ( + + ))} +
+ ) : null; + + return { showMenu, hideMenu, MenuPortal }; +} diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index d70552f..ae32fda 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -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([]); - const [selectedGroupId, setSelectedGroupId] = useState(''); + const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || ''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/web/src/components/ServerBar.tsx b/web/src/components/ServerBar.tsx index 9c36995..96e25d6 100644 --- a/web/src/components/ServerBar.tsx +++ b/web/src/components/ServerBar.tsx @@ -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(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 ( <>
@@ -57,6 +85,7 @@ export function ServerBar() {
{showCreate && setShowCreate(false)} />} {showJoin && setShowJoin(false)} />} + {settingsServerId && ( + setSettingsServerId(null)} /> + )} + {inviteServer && ( + setInviteServer(null)} /> + )} + {MenuPortal} ); } diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 6f7fc09..0de5bc9 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file