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
+17
View File
@@ -111,6 +111,12 @@ export function runMigrations(db: Database.Database): void {
columns: [
{ name: 'avatar_color', type: 'TEXT' },
]
},
{
name: 'channels',
columns: [
{ name: 'category_id', type: 'TEXT' },
]
}
];
@@ -168,6 +174,17 @@ export function runMigrations(db: Database.Database): void {
);
`);
// Ensure channel_categories table exists (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS channel_categories (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
`);
// Ensure voice_restrictions table exists (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS voice_restrictions (
+9
View File
@@ -42,6 +42,14 @@ export const spaceMembers = sqliteTable('space_members', {
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
}));
export const channelCategories = sqliteTable('channel_categories', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
position: integer('position').default(0),
createdAt: integer('created_at').notNull(),
});
export const channels = sqliteTable('channels', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
@@ -49,6 +57,7 @@ export const channels = sqliteTable('channels', {
type: text('type').notNull(),
topic: text('topic'),
position: integer('position').default(0),
categoryId: text('category_id'),
createdAt: integer('created_at').notNull(),
});
+318 -2
View File
@@ -11,6 +11,7 @@ import type {
CreateChannelRequest,
UpdateChannelRequest,
Channel,
ChannelCategory,
} from '@backspace/shared';
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
@@ -21,6 +22,17 @@ 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,
};
}
function rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory {
return {
id: row.id,
spaceId: row.spaceId,
name: row.name,
position: row.position ?? 0,
createdAt: row.createdAt,
};
}
@@ -96,7 +108,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic } = request.body;
const { name, type, topic, categoryId } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
@@ -121,6 +133,18 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
}
// Validate categoryId if provided
let validCategoryId: string | null = null;
if (categoryId) {
const cat = db.select().from(schema.channelCategories)
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id)))
.get();
if (!cat) {
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
}
validCategoryId = categoryId;
}
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
@@ -139,6 +163,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
type,
topic: topic?.trim() || null,
position: maxPosition + 1,
categoryId: validCategoryId,
createdAt: now,
}).run();
@@ -172,7 +197,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, topic, position } = request.body;
const { name, topic, position, categoryId } = request.body;
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
@@ -206,6 +231,20 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
updates.position = position;
}
if (categoryId !== undefined) {
if (categoryId === null) {
updates.categoryId = null;
} else {
const cat = db.select().from(schema.channelCategories)
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId)))
.get();
if (!cat) {
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
}
updates.categoryId = categoryId;
}
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
@@ -394,4 +433,281 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
},
);
// ─── Channel Category Endpoints ─────────────────────────────────────────────
// POST /api/spaces/:id/categories - Create a category
app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!name || typeof name !== 'string' || !name.trim()) {
return reply.code(400).send({ error: 'Category name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length > 100) {
return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 });
}
const existing = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id))
.all();
const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1);
const categoryId = generateSnowflake();
const now = Date.now();
db.insert(schema.channelCategories).values({
id: categoryId,
spaceId: id,
name: trimmedName,
position: maxPos + 1,
createdAt: now,
}).run();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, categoryId)).get();
if (!category) {
return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 });
}
const categoryData = rowToCategory(category);
connectionManager.sendToSpace(id, {
type: 'category_created',
category: categoryData,
spaceId: id,
});
return reply.code(201).send(categoryData);
});
// PATCH /api/categories/:id - Update a category
app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, position } = request.body;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const updates: Partial<typeof schema.channelCategories.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (!trimmedName || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
if (position !== undefined) {
if (typeof position !== 'number' || position < 0) {
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
}
updates.position = position;
}
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.channelCategories).set(updates)
.where(eq(schema.channelCategories.id, id)).run();
const updated = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 });
}
const categoryData = rowToCategory(updated);
connectionManager.sendToSpace(category.spaceId, {
type: 'category_updated',
category: categoryData,
spaceId: category.spaceId,
});
return reply.code(200).send(categoryData);
});
// DELETE /api/categories/:id - Delete a category
app.delete<{ Params: { id: string } }>('/api/categories/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const category = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).get();
if (!category) {
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
}
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
const spaceId = category.spaceId;
db.transaction((tx) => {
// Null out categoryId on all channels in this category
tx.update(schema.channels).set({ categoryId: null })
.where(eq(schema.channels.categoryId, id)).run();
// Delete the category
tx.delete(schema.channelCategories)
.where(eq(schema.channelCategories.id, id)).run();
});
// Broadcast category deletion
connectionManager.sendToSpace(spaceId, {
type: 'category_deleted',
categoryId: id,
spaceId,
});
// Also broadcast updated layout so channels reflect null categoryId
broadcastChannelLayout(spaceId);
return reply.code(200).send({ success: true });
});
// PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories
app.patch<{
Params: { id: string };
Body: {
channels: Array<{ id: string; position: number; categoryId: string | null }>;
categories: Array<{ id: string; position: number }>;
};
}>('/api/spaces/:id/channel-layout', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { channels: channelUpdates, categories: categoryUpdates } = request.body;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!space) {
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) {
return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 });
}
// Validate all channel IDs belong to this space
const spaceChannels = db.select().from(schema.channels)
.where(eq(schema.channels.spaceId, id)).all();
const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id));
for (const ch of channelUpdates) {
if (!spaceChannelIds.has(ch.id)) {
return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 });
}
if (typeof ch.position !== 'number' || ch.position < 0) {
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
}
}
// Validate all category IDs belong to this space
const spaceCategories = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id)).all();
const spaceCategoryIds = new Set(spaceCategories.map(c => c.id));
for (const cat of categoryUpdates) {
if (!spaceCategoryIds.has(cat.id)) {
return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 });
}
if (typeof cat.position !== 'number' || cat.position < 0) {
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
}
}
// Validate category references in channels
for (const ch of channelUpdates) {
if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) {
return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 });
}
}
// Apply all updates in a transaction
db.transaction((tx) => {
for (const ch of channelUpdates) {
tx.update(schema.channels)
.set({ position: ch.position, categoryId: ch.categoryId })
.where(eq(schema.channels.id, ch.id))
.run();
}
for (const cat of categoryUpdates) {
tx.update(schema.channelCategories)
.set({ position: cat.position })
.where(eq(schema.channelCategories.id, cat.id))
.run();
}
});
// Broadcast the updated layout to all space members with per-user channel filtering
broadcastChannelLayout(id);
return reply.code(200).send({ success: true });
});
}
/**
* Broadcast updated channel layout to all space members.
* Each user gets only the channels they can view (VIEW_CHANNEL check).
*/
function broadcastChannelLayout(spaceId: string): void {
const db = getDb();
const allChannels = db.select().from(schema.channels)
.where(eq(schema.channels.spaceId, spaceId)).all();
const allCategories = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, spaceId)).all();
const categoryData = allCategories.map(rowToCategory);
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
const visibleChannels: Channel[] = [];
for (const ch of allChannels) {
const perms = computePermissions(userId, spaceId, ch.id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
myPermissions: permissionsToString(perms),
});
}
}
connectionManager.sendToUser(userId, {
type: 'channel_layout_updated',
spaceId,
channels: visibleChannels,
categories: categoryData,
});
}
}
+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,
+21
View File
@@ -11,6 +11,7 @@ import type {
SpaceWithChannelsAndMembers,
MemberWithUser,
Channel,
ChannelCategory,
DmChannel,
ServerEvent,
SpaceFolder,
@@ -694,6 +695,24 @@ function buildReadyPayload(userId: string): {
arr.push(ch);
}
// Batch: all categories for all spaces (1 query instead of N)
const allCategories = batchInArray(
spaceIds,
ids => db.select().from(schema.channelCategories).where(inArray(schema.channelCategories.spaceId, ids)).all(),
);
const categoriesBySpace = new Map<string, ChannelCategory[]>();
for (const cat of allCategories) {
let arr = categoriesBySpace.get(cat.spaceId);
if (!arr) { arr = []; categoriesBySpace.set(cat.spaceId, arr); }
arr.push({
id: cat.id,
spaceId: cat.spaceId,
name: cat.name,
position: cat.position ?? 0,
createdAt: cat.createdAt,
});
}
// Batch: last message ID per channel (1 query instead of N×C)
const allChannelIds = allChannels.map(ch => ch.id);
const lastMsgMap = new Map<string, string>();
@@ -782,6 +801,7 @@ function buildReadyPayload(userId: string): {
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
categoryId: ch.categoryId ?? null,
createdAt: ch.createdAt,
lastMessageId: lastMsgMap.get(ch.id) ?? null,
myPermissions: permissionsToString(chPerms),
@@ -801,6 +821,7 @@ function buildReadyPayload(userId: string): {
description: spaceRow.description ?? null,
createdAt: spaceRow.createdAt,
channels: visibleChannels,
categories: categoriesBySpace.get(spaceRow.id) ?? [],
members,
roles: roles.map(r => ({
id: r.id,
+16
View File
@@ -78,6 +78,7 @@ export interface JoinRequest {
export interface SpaceWithChannelsAndMembers extends Space {
channels: Channel[];
categories: ChannelCategory[];
members: MemberWithUser[];
roles: Role[];
myPermissions?: string; // Computed per-user BigInt decimal string (space-level)
@@ -125,6 +126,14 @@ export interface SpaceFolder {
export type ChannelType = 'text' | 'voice';
export interface ChannelCategory {
id: string;
spaceId: string;
name: string;
position: number;
createdAt: number;
}
export interface Channel {
id: string;
spaceId: string;
@@ -132,6 +141,7 @@ export interface Channel {
type: ChannelType;
topic: string | null;
position: number;
categoryId: string | null;
createdAt: number;
lastMessageId?: string | null;
myPermissions?: string; // Computed per-user BigInt decimal string
@@ -297,6 +307,10 @@ export type ServerEvent =
| { type: 'voice_disconnected'; userId: string; channelId: string }
| { type: 'user_updated'; user: User }
| { type: 'member_banned'; spaceId: string; reason: string | null }
| { type: 'category_created'; category: ChannelCategory; spaceId: string }
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
| { type: 'category_deleted'; categoryId: string; spaceId: string }
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
| { type: 'pong' }
| { type: 'error'; message: string };
@@ -334,12 +348,14 @@ export interface CreateChannelRequest {
name: string;
type: ChannelType;
topic?: string;
categoryId?: string;
}
export interface UpdateChannelRequest {
name?: string;
topic?: string;
position?: number;
categoryId?: string | null;
}
export interface UpdateSpaceRequest {
+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 = {
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import type { Channel } from '@backspace/shared';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
@@ -96,9 +97,213 @@ export function ChannelSidebar() {
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
const categories = useSpaceStore((s) => s.categories);
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice');
// Drag state for channel/category reordering
const [channelDragState, setChannelDragState] = useState<{ dragType: 'channel' | 'category'; dragId: string } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after'; type: 'channel' | 'category' } | null>(null);
// Collapse state — persisted in localStorage
const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`;
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem(collapseKey);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch { return new Set(); }
});
const toggleCollapse = useCallback((categoryId: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
try { localStorage.setItem(collapseKey, JSON.stringify([...next])); } catch {}
return next;
});
}, [collapseKey]);
// Group channels by category
const sortedCategories = useMemo(() =>
[...categories].sort((a, b) => a.position - b.position), [categories]);
const uncategorizedChannels = useMemo(() =>
channels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [channels]);
const channelsByCategory = useMemo(() => {
const map = new Map<string, typeof channels>();
for (const ch of channels) {
if (!ch.categoryId) continue;
let arr = map.get(ch.categoryId);
if (!arr) { arr = []; map.set(ch.categoryId, arr); }
arr.push(ch);
}
for (const [key, arr] of map) {
map.set(key, arr.sort((a, b) => a.position - b.position));
}
return map;
}, [channels]);
// Check if a collapsed category has unread channels
const categoryHasUnread = useCallback((categoryId: string) => {
const chs = channelsByCategory.get(categoryId) ?? [];
return chs.some(ch => unreadChannels.has(ch.id));
}, [channelsByCategory, unreadChannels]);
// DnD handlers
const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => {
if (!canManageChannels) return;
e.dataTransfer.setData('application/x-channel-id', channelId);
e.dataTransfer.effectAllowed = 'move';
setChannelDragState({ dragType: 'channel', dragId: channelId });
}, [canManageChannels]);
const handleCategoryDragStart = useCallback((e: React.DragEvent, categoryId: string) => {
if (!canManageChannels) return;
e.dataTransfer.setData('application/x-category-id', categoryId);
e.dataTransfer.effectAllowed = 'move';
setChannelDragState({ dragType: 'category', dragId: categoryId });
}, [canManageChannels]);
const handleDragEnd = useCallback(() => {
setChannelDragState(null);
setDropIndicator(null);
}, []);
const handleChannelDragOver = useCallback((e: React.DragEvent, targetId: string, type: 'channel' | 'category') => {
if (!channelDragState) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position = e.clientY < midY ? 'before' : 'after';
setDropIndicator({ targetId, position, type });
}, [channelDragState]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!channelDragState || !currentSpaceId || !dropIndicator) {
setChannelDragState(null);
setDropIndicator(null);
return;
}
const dragChannelId = e.dataTransfer.getData('application/x-channel-id');
const dragCategoryId = e.dataTransfer.getData('application/x-category-id');
if (dragChannelId && dropIndicator) {
// Channel drag — compute new layout
const allChannelsCopy = channels.map(ch => ({
id: ch.id,
position: ch.position,
categoryId: ch.categoryId,
}));
if (dropIndicator.type === 'channel') {
// Dropping on a channel — take its categoryId and insert near it
const targetCh = channels.find(c => c.id === dropIndicator.targetId);
if (targetCh) {
const dragCh = allChannelsCopy.find(c => c.id === dragChannelId);
if (dragCh) {
dragCh.categoryId = targetCh.categoryId;
}
}
} else if (dropIndicator.type === 'category') {
// Dropping on a category header — move channel into that category
const dragCh = allChannelsCopy.find(c => c.id === dragChannelId);
if (dragCh) {
dragCh.categoryId = dropIndicator.targetId;
}
}
// Recalculate positions: group by category and assign sequential positions
const grouped = new Map<string | null, typeof allChannelsCopy>();
for (const ch of allChannelsCopy) {
const key = ch.categoryId;
let arr = grouped.get(key);
if (!arr) { arr = []; grouped.set(key, arr); }
arr.push(ch);
}
// Within each group, move dragged channel to correct position
for (const [, arr] of grouped) {
arr.sort((a, b) => a.position - b.position);
const dragIdx = arr.findIndex(c => c.id === dragChannelId);
if (dragIdx === -1) continue;
const dragItem = arr[dragIdx]!;
arr.splice(dragIdx, 1);
// Find target position
if (dropIndicator.type === 'channel') {
const targetIdx = arr.findIndex(c => c.id === dropIndicator.targetId);
if (targetIdx !== -1) {
const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1;
arr.splice(insertIdx, 0, dragItem);
} else {
arr.push(dragItem);
}
} else {
// Dropped on category header — add at start
arr.unshift(dragItem);
}
// Reassign positions
arr.forEach((ch, i) => { ch.position = i; });
}
const channelUpdates = allChannelsCopy.map(ch => ({
id: ch.id,
position: ch.position,
categoryId: ch.categoryId,
}));
const categoryUpdates = sortedCategories.map(c => ({
id: c.id,
position: c.position,
}));
// Optimistic update
useSpaceStore.getState().setChannels(
channels.map(ch => {
const update = channelUpdates.find(u => u.id === ch.id);
if (update) return { ...ch, position: update.position, categoryId: update.categoryId };
return ch;
}).sort((a, b) => a.position - b.position)
);
useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates });
} else if (dragCategoryId && dropIndicator?.type === 'category') {
// Category drag — reorder categories
const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position }));
const dragIdx = catsCopy.findIndex(c => c.id === dragCategoryId);
if (dragIdx !== -1) {
const dragItem = catsCopy[dragIdx]!;
catsCopy.splice(dragIdx, 1);
const targetIdx = catsCopy.findIndex(c => c.id === dropIndicator.targetId);
if (targetIdx !== -1) {
const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1;
catsCopy.splice(insertIdx, 0, dragItem);
} else {
catsCopy.push(dragItem);
}
catsCopy.forEach((c, i) => { c.position = i; });
const channelUpdates = channels.map(ch => ({
id: ch.id,
position: ch.position,
categoryId: ch.categoryId,
}));
// Optimistic update
useSpaceStore.getState().setCategories(
categories.map(cat => {
const update = catsCopy.find(u => u.id === cat.id);
if (update) return { ...cat, position: update.position };
return cat;
}).sort((a, b) => a.position - b.position)
);
useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy });
}
}
setChannelDragState(null);
setDropIndicator(null);
}, [channelDragState, dropIndicator, channels, sortedCategories, categories, currentSpaceId]);
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
@@ -362,125 +567,174 @@ export function ChannelSidebar() {
)}
</div>
{/* Channels */}
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
{/* Text Channels */}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
<span className="text-[11px] font-medium uppercase tracking-[0.06em]" style={{ color: '#484854' }}>Text Channels</span>
</div>
{canManageChannels && (
<button
onClick={(e) => {
e.stopPropagation();
openModal('createChannel');
}}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
<div className="space-y-[2px]">
{textChannels.map((channel) => {
const isActive = currentChannelId === channel.id;
const isUnread = unreadChannels.has(channel.id) && !isActive;
return (
{/* Channels — dynamic category layout */}
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[2px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{/* Uncategorized channels */}
{uncategorizedChannels.length > 0 && (
<div className="mb-[19px]">
{canManageChannels && sortedCategories.length === 0 && (
<div className="flex items-center justify-end px-1 mb-1">
<button
key={channel.id}
onClick={() => handleChannelClick(channel.id)}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
isActive
? 'bg-surface-elevated text-txt-primary'
: isUnread
? 'text-white hover:text-white hover:bg-interactive-hover'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
onClick={() => openModal('createChannel')}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
title="Create Channel"
>
{isActive && (
<div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }}
/>
)}
{isUnread && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-accent-rose" />
)}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
<span className={`truncate text-[15px] flex-1 text-left ${isUnread ? 'font-semibold' : 'font-medium'}`}>{channel.name}</span>
{canManageChannels && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
onClick={(e) => {
e.stopPropagation();
openModal('channelSettings', { channelId: channel.id });
}}
>
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
)}
</button>
);
})}
</div>
</div>
{/* Voice Channels */}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
<span className="text-[11px] font-medium uppercase tracking-[0.06em]" style={{ color: '#484854' }}>Voice Channels</span>
</div>
{canManageChannels && (
<button
onClick={(e) => {
e.stopPropagation();
openModal('createChannel');
}}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
</div>
)}
</div>
<div className="space-y-[2px]">
{voiceChannels.map((channel) => {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
return (
<VoiceChannel
<div className="space-y-[2px]">
{uncategorizedChannels.map((channel) => (
<ChannelItem
key={channel.id}
channelId={channel.id}
channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
dragState={voiceDragState}
onDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
onDragEnd={() => setVoiceDragState(null)}
channel={channel}
isActive={currentChannelId === channel.id}
isUnread={unreadChannels.has(channel.id) && currentChannelId !== channel.id}
canManage={canManageChannels}
isDragging={channelDragState?.dragType === 'channel' && channelDragState.dragId === channel.id}
dropIndicator={dropIndicator?.targetId === channel.id ? dropIndicator.position : null}
onChannelClick={channel.type === 'voice' ? (() => {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
if (canConnect) handleVoiceJoin(channel.id);
}) : (() => handleChannelClick(channel.id))}
onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })}
onDragStart={(e) => handleChannelDragStart(e, channel.id)}
onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')}
onDragEnd={handleDragEnd}
voiceDragState={voiceDragState}
onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
onVoiceDragEnd={() => setVoiceDragState(null)}
channelPermissions={channelPermissions}
handleVoiceJoin={handleVoiceJoin}
/>
);
})}
))}
</div>
</div>
</div>
)}
{/* Categories with their channels */}
{sortedCategories.map((category) => {
const catChannels = channelsByCategory.get(category.id) ?? [];
const isCollapsed = collapsedCategories.has(category.id);
const hasUnread = isCollapsed && categoryHasUnread(category.id);
return (
<div key={category.id} className="mb-[19px]">
{/* Category header */}
<div
className={`flex items-center justify-between px-1 mb-1 group cursor-pointer ${
channelDragState?.dragType === 'category' && channelDragState.dragId === category.id ? 'opacity-50' : ''
} ${dropIndicator?.targetId === category.id && dropIndicator.type === 'category' ? 'ring-1 ring-accent-mint/40 rounded' : ''}`}
draggable={canManageChannels}
onDragStart={(e) => handleCategoryDragStart(e, category.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleChannelDragOver(e, category.id, 'category')}
onClick={() => toggleCollapse(category.id)}
>
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors min-w-0">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={`opacity-70 transition-transform flex-shrink-0 ${isCollapsed ? '-rotate-90' : ''}`}>
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
<span className="text-[11px] font-medium uppercase tracking-[0.06em] truncate" style={{ color: '#484854' }}>{category.name}</span>
{hasUnread && (
<div className="ml-1 w-1.5 h-1.5 rounded-full bg-accent-rose flex-shrink-0" />
)}
</div>
{canManageChannels && (
<button
onClick={(e) => {
e.stopPropagation();
openModal('createChannel', { categoryId: category.id });
}}
className="text-txt-tertiary hover:text-txt-primary transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
{/* Category channels (hidden when collapsed, unless active) */}
{!isCollapsed && (
<div className="space-y-[2px]">
{catChannels.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
isActive={currentChannelId === channel.id}
isUnread={unreadChannels.has(channel.id) && currentChannelId !== channel.id}
canManage={canManageChannels}
isDragging={channelDragState?.dragType === 'channel' && channelDragState.dragId === channel.id}
dropIndicator={dropIndicator?.targetId === channel.id ? dropIndicator.position : null}
onChannelClick={channel.type === 'voice' ? (() => {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
if (canConnect) handleVoiceJoin(channel.id);
}) : (() => handleChannelClick(channel.id))}
onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })}
onDragStart={(e) => handleChannelDragStart(e, channel.id)}
onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')}
onDragEnd={handleDragEnd}
voiceDragState={voiceDragState}
onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
onVoiceDragEnd={() => setVoiceDragState(null)}
channelPermissions={channelPermissions}
handleVoiceJoin={handleVoiceJoin}
/>
))}
{catChannels.length === 0 && (
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div>
)}
</div>
)}
</div>
);
})}
{/* "Create Channel" button if categories exist but no uncategorized channels */}
{sortedCategories.length > 0 && canManageChannels && (
<div className="px-1">
<button
onClick={() => openModal('createChannel')}
className="w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
<span className="text-[13px] font-medium">Create Channel</span>
</button>
</div>
)}
{/* Create category button */}
{canManageChannels && (
<div className="px-1">
<button
onClick={async () => {
if (!currentSpaceId) return;
const name = prompt('Category name:');
if (name?.trim()) {
try {
await useSpaceStore.getState().createCategory(currentSpaceId, name.trim());
} catch (err) {
console.error('Failed to create category:', err);
}
}
}}
className="w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
<span className="text-[13px] font-medium">Create Category</span>
</button>
</div>
)}
</div>
@@ -889,3 +1143,119 @@ function UserAreaPanel({
</div>
);
}
/* ─── Channel Item (unified text + voice) ──────────────────────────────────── */
function ChannelItem({
channel,
isActive,
isUnread,
canManage,
isDragging,
dropIndicator,
onChannelClick,
onSettingsClick,
onDragStart,
onDragOver,
onDragEnd,
voiceDragState,
onVoiceDragStart,
onVoiceDragEnd,
channelPermissions,
handleVoiceJoin,
}: {
channel: Channel;
isActive: boolean;
isUnread: boolean;
canManage: boolean;
isDragging: boolean;
dropIndicator: 'before' | 'after' | null;
onChannelClick: () => void;
onSettingsClick: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnd: () => void;
voiceDragState: { userId: string; fromChannelId: string } | null;
onVoiceDragStart: (userId: string) => void;
onVoiceDragEnd: () => void;
channelPermissions: Map<string, string>;
handleVoiceJoin: (channelId: string) => void;
}) {
if (channel.type === 'voice') {
const chPerms = channelPermissions.get(channel.id);
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
return (
<div
className={`relative ${isDragging ? 'opacity-50' : ''}`}
draggable={canManage}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
<VoiceChannel
channelId={channel.id}
channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
dragState={voiceDragState}
onDragStart={onVoiceDragStart}
onDragEnd={onVoiceDragEnd}
/>
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
</div>
);
}
return (
<div
className={`relative ${isDragging ? 'opacity-50' : ''}`}
draggable={canManage}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
<button
onClick={onChannelClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
isActive
? 'bg-surface-elevated text-txt-primary'
: isUnread
? 'text-white hover:text-white hover:bg-interactive-hover'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{isActive && (
<div
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
style={{ height: '55%', opacity: 0.7 }}
/>
)}
{isUnread && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-accent-rose" />
)}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className={`truncate text-[15px] flex-1 text-left ${isUnread ? 'font-semibold' : 'font-medium'}`}>{channel.name}</span>
{canManage && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
onClick={(e) => {
e.stopPropagation();
onSettingsClick();
}}
>
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
)}
</button>
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
</div>
);
}
@@ -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">
+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': {
+66 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity';
@@ -26,6 +26,7 @@ interface SpaceState {
spaces: TaggedSpace[];
currentSpaceId: string | null;
channels: Channel[];
categories: ChannelCategory[];
members: MemberWithUser[];
roles: Role[];
folders: SpaceFolder[];
@@ -35,9 +36,11 @@ interface SpaceState {
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
setSpaces: (spaces: TaggedSpace[]) => void;
setCurrentSpace: (spaceId: string | null) => void;
setChannels: (channels: Channel[]) => void;
setCategories: (categories: ChannelCategory[]) => void;
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
@@ -57,8 +60,12 @@ interface SpaceState {
leaveSpace: (spaceId: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
generateInvite: (spaceId: string) => Promise<string>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => Promise<Channel>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
createCategory: (spaceId: string, name: string) => Promise<ChannelCategory>;
updateCategory: (categoryId: string, data: { name?: string; position?: number }) => Promise<void>;
deleteCategory: (categoryId: string) => Promise<void>;
updateChannelLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<void>;
addSpace: (space: Space) => void;
removeSpace: (spaceId: string) => void;
updateMemberPresence: (userId: string, status: string) => void;
@@ -76,6 +83,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [],
currentSpaceId: null,
channels: [],
categories: [],
members: [],
roles: [],
folders: [],
@@ -85,10 +93,12 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions: new Map(),
channelPermissions: new Map(),
channelOriginMap: new Map(),
categoryOriginMap: new Map(),
setSpaces: (spaces) => set({ spaces }),
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
setChannels: (channels) => set({ channels }),
setCategories: (categories) => set({ categories }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
@@ -185,6 +195,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
set({
currentSpaceId: spaceId,
channels: detail.channels.sort((a, b) => a.position - b.position),
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position),
spacePermissions,
@@ -294,8 +305,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
return result.inviteCode;
},
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => {
const channel = await api.channels.create(spaceId, { name, type, topic });
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => {
const space = get().spaces.find(s => s.id === spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const channel = await client.channels.create(spaceId, { name, type, topic, categoryId });
set((state) => {
if (state.channels.some(c => c.id === channel.id)) return state;
return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) };
@@ -310,6 +324,47 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}));
},
createCategory: async (spaceId: string, name: string) => {
const space = get().spaces.find(s => s.id === spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const category = await client.categories.create(spaceId, name);
// Will be added via WS event, but add optimistically
set((state) => {
if (state.categories.some(c => c.id === category.id)) return state;
return { categories: [...state.categories, category].sort((a, b) => a.position - b.position) };
});
return category;
},
updateCategory: async (categoryId: string, data: { name?: string; position?: number }) => {
const cat = get().categories.find(c => c.id === categoryId);
if (!cat) return;
const space = get().spaces.find(s => s.id === cat.spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
await client.categories.update(categoryId, data);
// WS event will update the store
},
deleteCategory: async (categoryId: string) => {
const cat = get().categories.find(c => c.id === categoryId);
if (!cat) return;
const space = get().spaces.find(s => s.id === cat.spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
await client.categories.delete(categoryId);
// WS events will update the store
},
updateChannelLayout: async (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => {
const space = get().spaces.find(s => s.id === spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
await client.channels.updateLayout(spaceId, data);
// WS event will broadcast the updated layout
},
addSpace: (space: Space) => {
set((state) => {
if (state.spaces.find(s => s.id === space.id)) return state;
@@ -386,6 +441,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
const categoryOriginMap = new Map(get().categoryOriginMap);
// If home, clear home-origin entries first to avoid stale data
if (isHome) {
@@ -435,6 +491,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
channelPermissions.set(ch.id, ch.myPermissions);
}
}
if (srv.categories) {
for (const cat of srv.categories) {
categoryOriginMap.set(cat.id, origin);
}
}
}
// DM channels: process from any origin, normalize remote assets
@@ -467,6 +528,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions,
channelPermissions,
channelOriginMap,
categoryOriginMap,
};
// Only set folders from home origin