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
+49
View File
@@ -651,6 +651,55 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
// ─── Category events (all origins) ────────────────────────────────────
case 'category_created': {
const { currentSpaceId: catSpaceId, categories: curCategories, setCategories: setCats, categoryOriginMap: catOriginMap } = useSpaceStore.getState();
catOriginMap.set(event.category.id, origin);
if (event.spaceId === catSpaceId) {
if (!curCategories.some(c => c.id === event.category.id)) {
setCats([...curCategories, event.category].sort((a, b) => a.position - b.position));
}
}
break;
}
case 'category_updated': {
const { currentSpaceId: catSpaceId2, categories: curCategories2, setCategories: setCats2 } = useSpaceStore.getState();
if (event.spaceId === catSpaceId2) {
setCats2(curCategories2.map(c => c.id === event.category.id ? event.category : c).sort((a, b) => a.position - b.position));
}
break;
}
case 'category_deleted': {
const { currentSpaceId: catSpaceId3, categories: curCategories3, setCategories: setCats3, channels: curChsForCat, setChannels: setChsForCat, categoryOriginMap: catOriginMap3 } = useSpaceStore.getState();
catOriginMap3.delete(event.categoryId);
if (event.spaceId === catSpaceId3) {
setCats3(curCategories3.filter(c => c.id !== event.categoryId));
// Null out categoryId on affected channels (server already did this, but sync local state)
setChsForCat(curChsForCat.map(ch => ch.categoryId === event.categoryId ? { ...ch, categoryId: null } : ch));
}
break;
}
case 'channel_layout_updated': {
const { currentSpaceId: layoutSpaceId, setChannels: setLayoutChannels, setCategories: setLayoutCategories, channelPermissions: layoutChPerms, channelToSpaceMap: layoutCtsMap, channelOriginMap: layoutCoMap } = useSpaceStore.getState();
if (event.spaceId === layoutSpaceId) {
setLayoutChannels(event.channels.sort((a, b) => a.position - b.position));
setLayoutCategories(event.categories.sort((a, b) => a.position - b.position));
// Update permission maps from the new layout
for (const ch of event.channels) {
layoutCtsMap.set(ch.id, event.spaceId);
layoutCoMap.set(ch.id, origin);
if (ch.myPermissions) {
layoutChPerms.set(ch.id, ch.myPermissions);
}
}
}
break;
}
// ─── Join request events (home-only) ────────────────────────────────
case 'join_request_received': {