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
+16
View File
@@ -14,6 +14,7 @@ import type {
UpdateMemberRequest,
Space,
Channel,
ChannelCategory,
MemberWithUser,
SpaceWithChannelsAndMembers,
Role,
@@ -45,6 +46,7 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
type: row.type as Channel['type'],
topic: row.topic,
position: row.position ?? 0,
categoryId: row.categoryId ?? null,
createdAt: row.createdAt,
};
}
@@ -239,6 +241,19 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
})
.filter((m): m is MemberWithUser => m !== null);
// Fetch categories for this space
const categoryRows = db.select()
.from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id))
.all();
const categories: ChannelCategory[] = categoryRows.map(c => ({
id: c.id,
spaceId: c.spaceId,
name: c.name,
position: c.position ?? 0,
createdAt: c.createdAt,
}));
// Compute space-level permissions for the requesting user
const spacePerms = computePermissions(request.userId, id);
@@ -259,6 +274,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
const result: SpaceWithChannelsAndMembers = {
...rowToSpace(server),
channels: visibleChannels,
categories,
members,
roles: roles.map(r => ({
id: r.id,