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,