feat: channel categories with drag-and-drop reordering

Add channel categories (named groups) with full CRUD, collapsible headers
with unread indicators, and native HTML5 drag-and-drop for reordering
channels within/between categories and reordering categories themselves.

- Schema: channel_categories table, category_id column on channels
- Server: category CRUD endpoints, batch channel-layout reorder endpoint
- WebSocket: categories in ready payload, category_created/updated/deleted
  and channel_layout_updated events with per-user VIEW_CHANNEL filtering
- Frontend: dynamic category-based sidebar layout replacing hardcoded
  Text/Voice sections, collapse state persisted to localStorage,
  category selector in CreateChannel modal, federation-aware store
This commit is contained in:
Jannis Braun
2026-03-12 01:19:06 +01:00
parent b6d44f1568
commit c51d6b1a0e
11 changed files with 1045 additions and 121 deletions
+19
View File
@@ -6,6 +6,7 @@ import type {
Space,
SpaceWithChannelsAndMembers,
Channel,
ChannelCategory,
MessageWithUser,
MemberWithUser,
Attachment,
@@ -92,6 +93,13 @@ export class BackspaceApiClient {
getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>;
putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>;
deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>;
updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<{ success: boolean }>;
};
readonly categories: {
create: (spaceId: string, name: string) => Promise<ChannelCategory>;
update: (id: string, data: { name?: string; position?: number }) => Promise<ChannelCategory>;
delete: (id: string) => Promise<{ success: boolean }>;
};
readonly messages: {
@@ -313,6 +321,17 @@ export class BackspaceApiClient {
request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data),
deleteOverride: (channelId: string, targetType: string, targetId: string) =>
request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`),
updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) =>
request<{ success: boolean }>('PATCH', `/spaces/${spaceId}/channel-layout`, data),
};
this.categories = {
create: (spaceId: string, name: string) =>
request<ChannelCategory>('POST', `/spaces/${spaceId}/categories`, { name }),
update: (id: string, data: { name?: string; position?: number }) =>
request<ChannelCategory>('PATCH', `/categories/${id}`, data),
delete: (id: string) =>
request<{ success: boolean }>('DELETE', `/categories/${id}`),
};
this.messages = {