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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user