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
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore } from '../../stores/spaceStore';
@@ -7,15 +7,25 @@ export function CreateChannelModal() {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice'>('text');
const [topic, setTopic] = useState('');
const [categoryId, setCategoryId] = useState<string | ''>('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useSpaceStore((s) => s.createChannel);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const categories = useSpaceStore((s) => s.categories);
const isOpen = activeModal === 'createChannel';
// Pre-select category when opened from a category's + button
useEffect(() => {
if (isOpen && modalData.categoryId) {
setCategoryId(modalData.categoryId as string);
}
}, [isOpen, modalData.categoryId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -32,11 +42,12 @@ export function CreateChannelModal() {
setIsLoading(true);
try {
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined);
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined, categoryId || undefined);
closeModal();
setName('');
setTopic('');
setType('text');
setCategoryId('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
} finally {
@@ -124,6 +135,24 @@ export function CreateChannelModal() {
</div>
)}
{categories.length > 0 && (
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
>
<option value="">No Category</option>
{[...categories].sort((a, b) => a.position - b.position).map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
)}
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">