From d66363838727d99b772efdaf12eef71d2eb5a141 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 12:50:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(phase8.1):=20server=20groups=20=E2=80=94?= =?UTF-8?q?=20table,=20CRUD=20handler,=20collapsible=20channel=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 6 + internal/channel/handlers.go | 56 ++-- internal/db/db.go | 13 + internal/servergroup/handlers.go | 309 ++++++++++++++++++++++ web/src/components/ChannelList.tsx | 251 +++++++++++++----- web/src/components/CreateChannelModal.tsx | 39 ++- web/src/stores/channel.ts | 1 + 7 files changed, 587 insertions(+), 88 deletions(-) create mode 100644 internal/servergroup/handlers.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 895b86e..2af8985 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/readstate" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/servergroup" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook" @@ -194,6 +195,11 @@ func main() { channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) }) + // Server groups (sub-server sections) + r.Route("/groups", func(r chi.Router) { + servergroup.NewHandler(database.DB).RegisterRoutes(r) + }) + // Availability r.Get("/availability", authHandler.GetServerAvailability) }) diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 6b1f7aa..16cc558 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -38,21 +38,23 @@ func (h *Handler) RegisterRoutes(r chi.Router) { } type createChannelRequest struct { - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Position *int `json:"position"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Position *int `json:"position"` + GroupID *string `json:"group_id"` } type channelResponse struct { - ID string `json:"id"` - ServerID string `json:"server_id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Position int `json:"position"` - SlowmodeSeconds int `json:"slowmode_seconds"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Position int `json:"position"` + SlowmodeSeconds int `json:"slowmode_seconds"` + GroupID *string `json:"group_id"` + CreatedAt string `json:"created_at"` } // isMember checks whether the given user is a member of the given server. @@ -133,11 +135,11 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var ch channelResponse err = h.db.QueryRowContext(r.Context(), ` - INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds) - VALUES ($1, $2, $3, $4, $5, 0) - RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at - `, serverID, req.Name, channelType, category, position).Scan( - &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt, + INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds, group_id) + VALUES ($1, $2, $3, $4, $5, 0, $6) + RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at + `, serverID, req.Name, channelType, category, position, req.GroupID).Scan( + &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict) @@ -184,7 +186,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { } rows, err := h.db.QueryContext(r.Context(), ` - SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at + SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at FROM channels WHERE server_id = $1 ORDER BY position, name @@ -198,7 +200,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { channels := make([]channelResponse, 0) for rows.Next() { var ch channelResponse - if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt); err != nil { + if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } @@ -235,9 +237,9 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { var ch channelResponse err := h.db.QueryRowContext(r.Context(), ` - SELECT id, server_id, name, type, category, position, slowmode_seconds, created_at + SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at FROM channels WHERE id = $1 - `, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt) + `, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) @@ -267,6 +269,7 @@ type updateChannelRequest struct { Category *string `json:"category"` Position *int `json:"position"` SlowmodeSeconds *int `json:"slowmode_seconds"` + GroupID *string `json:"group_id"` } // @Summary Update a channel @@ -297,7 +300,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } - if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil { + if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil && req.GroupID == nil { http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) return } @@ -341,11 +344,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { type = COALESCE($2, type), category = COALESCE($3, category), position = COALESCE($4, position), - slowmode_seconds = COALESCE($5, slowmode_seconds) + slowmode_seconds = COALESCE($5, slowmode_seconds), + group_id = $7 WHERE id = $6 - RETURNING id, server_id, name, type, category, position, slowmode_seconds, created_at - `, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID).Scan( - &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.CreatedAt, + RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at + `, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan( + &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError) diff --git a/internal/db/db.go b/internal/db/db.go index f3e6eed..7448a9e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -479,4 +479,17 @@ DROP TRIGGER IF EXISTS messages_search_trigger ON messages; CREATE TRIGGER messages_search_trigger BEFORE INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION messages_search_update(); + +-- Server groups (sub-server sections for organizing channels) +CREATE TABLE IF NOT EXISTS server_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, name) +); +CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id); +ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL; ` + diff --git a/internal/servergroup/handlers.go b/internal/servergroup/handlers.go new file mode 100644 index 0000000..5077a19 --- /dev/null +++ b/internal/servergroup/handlers.go @@ -0,0 +1,309 @@ +package servergroup + +import ( + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type Handler struct { + db *sql.DB +} + +func NewHandler(db *sql.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/", h.Create) + r.Get("/", h.List) + r.Get("/{groupID}", h.Get) + r.Patch("/{groupID}", h.Update) + r.Delete("/{groupID}", h.Delete) +} + +type groupResponse struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` +} + +type createGroupRequest struct { + Name string `json:"name"` + Position *int `json:"position"` +} + +type updateGroupRequest struct { + Name *string `json:"name"` + Position *int `json:"position"` +} + +// @Summary Create a server group +// @Description Create a new channel group within a server +// @Tags server_groups +// @Accept json +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Param body body createGroupRequest true "Group data" +// @Success 201 {object} groupResponse +// @Failure 400 {object} map[string]string +// @Router /servers/{serverID}/groups [post] +func (h *Handler) Create(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") + + var req createGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == "" { + http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest) + return + } + + // Verify ownership or MANAGE_CHANNELS + 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 { + // Check permission + var hasPerm bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM role_permissions rp + JOIN member_roles mr ON mr.role_id = rp.role_id + WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' + ) + `, userID, serverID).Scan(&hasPerm) + if err != nil || !hasPerm { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + } + + var position int + if req.Position == nil { + // Auto-assign position (max + 1) + err = h.db.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(position), -1) + 1 FROM server_groups WHERE server_id = $1`, serverID).Scan(&position) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + } else { + position = *req.Position + } + + var grp groupResponse + err = h.db.QueryRowContext(r.Context(), ` + INSERT INTO server_groups (server_id, name, position) + VALUES ($1, $2, $3) + RETURNING id, server_id, name, position, created_at + `, serverID, req.Name, position).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt) + if err != nil { + http.Error(w, `{"error":"failed to create group (name may already exist)"}`, http.StatusConflict) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(grp) +} + +// @Summary List server groups +// @Description List all channel groups for a server, ordered by position +// @Tags server_groups +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Success 200 {array} groupResponse +// @Router /servers/{serverID}/groups [get] +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, server_id, name, position, created_at + FROM server_groups + WHERE server_id = $1 + ORDER BY position, name + `, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + groups := make([]groupResponse, 0) + for rows.Next() { + var grp groupResponse + if err := rows.Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + groups = append(groups, grp) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} + +// @Summary Get a server group +// @Tags server_groups +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Param groupID path string true "Group ID" +// @Success 200 {object} groupResponse +// @Router /servers/{serverID}/groups/{groupID} [get] +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + groupID := chi.URLParam(r, "groupID") + + var grp groupResponse + err := h.db.QueryRowContext(r.Context(), ` + SELECT id, server_id, name, position, created_at + FROM server_groups WHERE id = $1 + `, groupID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt) + if err != nil { + http.Error(w, `{"error":"group not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(grp) +} + +// @Summary Update a server group +// @Tags server_groups +// @Accept json +// @Produce json +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Param groupID path string true "Group ID" +// @Param body body updateGroupRequest true "Group update data" +// @Success 200 {object} groupResponse +// @Router /servers/{serverID}/groups/{groupID} [patch] +func (h *Handler) Update(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") + groupID := chi.URLParam(r, "groupID") + + var req updateGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == nil && req.Position == nil { + http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) + return + } + + 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 { + var hasPerm bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM role_permissions rp + JOIN member_roles mr ON mr.role_id = rp.role_id + WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' + ) + `, userID, serverID).Scan(&hasPerm) + if err != nil || !hasPerm { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + } + + var grp groupResponse + err = h.db.QueryRowContext(r.Context(), ` + UPDATE server_groups + SET name = COALESCE($1, name), + position = COALESCE($2, position) + WHERE id = $3 AND server_id = $4 + RETURNING id, server_id, name, position, created_at + `, req.Name, req.Position, groupID, serverID).Scan(&grp.ID, &grp.ServerID, &grp.Name, &grp.Position, &grp.CreatedAt) + if err != nil { + http.Error(w, `{"error":"group not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(grp) +} + +// @Summary Delete a server group +// @Tags server_groups +// @Security SessionAuth +// @Param serverID path string true "Server ID" +// @Param groupID path string true "Group ID" +// @Success 204 +// @Router /servers/{serverID}/groups/{groupID} [delete] +func (h *Handler) Delete(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") + groupID := chi.URLParam(r, "groupID") + + // Only owner or manage_channels can delete groups + 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 { + var hasPerm bool + err = h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS ( + SELECT 1 FROM role_permissions rp + JOIN member_roles mr ON mr.role_id = rp.role_id + WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels' + ) + `, userID, serverID).Scan(&hasPerm) + if err != nil || !hasPerm { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + } + + // Channels in this group get group_id set to NULL (via ON DELETE SET NULL) + result, err := h.db.ExecContext(r.Context(), `DELETE FROM server_groups WHERE id = $1 AND server_id = $2`, groupID, serverID) + if err != nil { + http.Error(w, `{"error":"failed to delete group"}`, http.StatusInternalServerError) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + http.Error(w, `{"error":"group not found"}`, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index f9fd94f..15cbeb1 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 { api } from '../lib/api.ts'; type NotifLevel = 'all' | 'mentions' | 'none'; @@ -18,6 +19,39 @@ const NOTIF_LABELS: Record = { const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none']; +interface ServerGroup { + id: string; + server_id: string; + name: string; + position: number; +} + +function collapsedKey(serverId: string, groupId: string) { + return 'collapsed:' + serverId + ':' + groupId; +} + +function isCollapsed(serverId: string, groupId: string): boolean { + try { + return localStorage.getItem(collapsedKey(serverId, groupId)) === '1'; + } catch { + return false; + } +} + +function toggleCollapsed(serverId: string, groupId: string) { + const key = collapsedKey(serverId, groupId); + const now = isCollapsed(serverId, groupId); + try { + if (now) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, '1'); + } + } catch { + // ignore + } +} + export function ChannelList() { const activeServerId = useServerStore((state) => state.activeServerId); const servers = useServerStore((state) => state.servers); @@ -28,6 +62,8 @@ export function ChannelList() { const [showCreate, setShowCreate] = useState(false); const [showInvite, setShowInvite] = useState(false); const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null); + const [groups, setGroups] = useState([]); + const [toggleTick, setToggleTick] = useState(0); const notifSettings = useNotificationSettingsStore((state) => state.settings); const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings); @@ -39,6 +75,11 @@ export function ChannelList() { useEffect(() => { if (activeServerId) { fetchChannels(activeServerId); + api.get('/servers/' + activeServerId + '/groups') + .then(setGroups) + .catch(() => setGroups([])); + } else { + setGroups([]); } }, [activeServerId, fetchChannels]); @@ -56,19 +97,43 @@ export function ChannelList() { return servers.find((s) => s.id === activeServerId) || null; }, [servers, activeServerId]); - const categories = useMemo(() => { - const map = new Map(); + // Build ordered sections: sorted groups first, then ungroupped channels by category + const sections = useMemo(() => { + type Section = { type: 'group'; group: ServerGroup; channels: typeof channels } | { type: 'category'; name: string; channels: typeof channels }; + + const grouped: Record = {}; + const ungrouped: Record = {}; + for (const channel of channels) { - const category = channel.category || 'TEXT CHANNELS'; - const list = map.get(category) || []; - list.push(channel); - map.set(category, list); + if (channel.group_id) { + const list = grouped[channel.group_id] || []; + list.push(channel); + grouped[channel.group_id] = list; + } else { + const cat = channel.category || 'TEXT CHANNELS'; + const list = ungrouped[cat] || []; + list.push(channel); + ungrouped[cat] = list; + } } - for (const list of map.values()) { - list.sort((a, b) => a.position - b.position); + + const result: Section[] = []; + + // Sorted groups + const sortedGroups = [...groups].sort((a, b) => a.position - b.position); + for (const grp of sortedGroups) { + const chs = (grouped[grp.id] || []).sort((a, b) => a.position - b.position); + result.push({ type: 'group', group: grp, channels: chs }); } - return Array.from(map.entries()); - }, [channels]); + + // Ungroupped channels by category + const sortedCats = Object.keys(ungrouped).sort(); + for (const cat of sortedCats) { + result.push({ type: 'category', name: cat, channels: ungrouped[cat].sort((a, b) => a.position - b.position) }); + } + + return result; + }, [channels, groups, toggleTick]); const getLevel = (channelId: string): NotifLevel => { return notifSettings[channelId] || 'all'; @@ -116,59 +181,125 @@ export function ChannelList() { )}
- {categories.length === 0 && ( + {sections.length === 0 && (

[no channels]

)} - {categories.map(([category, list]) => ( -
-
{category}
-
---
- {list.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} - > - [⚙] - - - + {sections.map((section) => { + if (section.type === 'group') { + const grp = section.group; + const collapsed = isCollapsed(activeServerId || '', grp.id); + return ( +
+
{ toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }} + > + {collapsed ? '[+]' : '[-]'} {grp.name}
- ) - )} -
- ))} + {!collapsed && ( + <> +
---
+ {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} + > + [⚙] + + + +
+ ) + )} + + )} +
+ ); + } + // category section (fallback for ungrouped channels) + return ( +
+
{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} + > + [⚙] + + + +
+ ) + )} +
+ ); + })}
{showCreate && activeServerId && ( setShowCreate(false)} /> diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index 21fed40..bb4fb4f 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -7,6 +7,14 @@ interface CreateChannelModalProps { onClose: () => void; } +interface ServerGroup { + id: string; + server_id: string; + name: string; + position: number; + created_at: string; +} + interface ChannelApiResponse { id: string; server_id: string; @@ -15,6 +23,7 @@ interface ChannelApiResponse { category: string; position: number; slowmode_seconds: number; + group_id: string | null; } const SLOWMODE_OPTIONS = [ @@ -32,9 +41,17 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp 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 [loading, setLoading] = useState(false); const [error, setError] = useState(null); + useEffect(() => { + api.get('/servers/' + serverId + '/groups') + .then(setGroups) + .catch(() => {}); + }, [serverId]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -53,12 +70,16 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp setLoading(true); setError(null); try { - const result = await api.post("/servers/" + serverId + "/channels", { + const body: Record = { name: name.trim(), type, category: category.trim() || 'general', slowmode_seconds: slowmodeSeconds, - }); + }; + if (selectedGroupId) { + body.group_id = selectedGroupId; + } + const result = await api.post('/servers/' + serverId + '/channels', body); const newChannel = { id: result.id, serverId: result.server_id, @@ -67,6 +88,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp category: result.category || null, position: result.position, slowmode_seconds: result.slowmode_seconds, + group_id: result.group_id, }; useChannelStore.getState().addChannel(newChannel); onClose(); @@ -118,6 +140,19 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
+
+ + +