feat: space sidebar drag-and-drop reordering with folder system

Add user_space_layout table and PUT /api/users/@me/space-layout endpoint
for persisting per-user sidebar ordering. Spaces can be freely reordered
via drag-and-drop, folders created by dragging one space onto another,
and folders auto-dissolve when they have fewer than 2 members. Includes
folder context menu (rename, color, ungroup), collapsed folder mini-grid
icons, multi-tab sync via WebSocket, and localStorage collapse state.
Removes the rigid native/federated split — federated spaces now intermix
freely while keeping their globe badge.
This commit is contained in:
Jannis Braun
2026-03-12 03:38:22 +01:00
parent 39174e5454
commit dbd964d40c
12 changed files with 1006 additions and 93 deletions
+15
View File
@@ -68,6 +68,19 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
.where(eq(schema.channels.spaceId, spaceId))
.all();
const categories = db.select()
.from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, spaceId))
.orderBy(schema.channelCategories.position)
.all()
.map(c => ({
id: c.id,
spaceId: c.spaceId,
name: c.name,
position: c.position ?? 0,
createdAt: c.createdAt,
}));
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.spaceId, spaceId))
@@ -135,6 +148,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
categoryId: ch.categoryId ?? null,
createdAt: ch.createdAt,
myPermissions: permissionsToString(chPerms),
});
@@ -153,6 +167,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
description: space.description ?? null,
createdAt: space.createdAt,
channels: visibleChannels,
categories,
members,
roles: roles.map(r => ({
id: r.id,
+5 -5
View File
@@ -1089,15 +1089,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
const userIds = [...new Set(banRows.map(b => b.userId))];
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
const allUserIds = [...new Set([...userIds, ...bannedByIds])];
const users = db.select().from(schema.users)
.where(inArray(schema.users.id, allUserIds))
.all();
const allUserIds = [...new Set([...userIds, ...bannedByIds].filter((id): id is string => id !== null))];
const users = allUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const bans = banRows.map(b => {
const user = userMap.get(b.userId);
const moderator = userMap.get(b.bannedBy);
const moderator = b.bannedBy ? userMap.get(b.bannedBy) : undefined;
return {
spaceId: b.spaceId,
userId: b.userId,
+161 -1
View File
@@ -4,10 +4,11 @@ import crypto from 'crypto';
import { getDb, schema } from '../db/index.js';
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
import { connectionManager } from '../ws/handler.js';
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared';
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { generateSnowflake } from '../utils/snowflake.js';
export async function userRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
@@ -442,6 +443,165 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(sanitized);
});
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> } }>(
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
const { items, folders } = request.body;
const userId = request.userId;
if (!Array.isArray(items)) {
return reply.code(400).send({ error: 'items must be an array', statusCode: 400 });
}
if (!folders || typeof folders !== 'object') {
return reply.code(400).send({ error: 'folders must be an object', statusCode: 400 });
}
// Validate items
for (const item of items) {
if (!item || (item.t !== 's' && item.t !== 'f') || typeof item.id !== 'string') {
return reply.code(400).send({ error: 'Each item must have t ("s" or "f") and id string', statusCode: 400 });
}
}
// Validate folders
for (const [key, folder] of Object.entries(folders)) {
if (!Array.isArray(folder.spaceIds)) {
return reply.code(400).send({ error: `Folder "${key}" must have spaceIds array`, statusCode: 400 });
}
}
const db = getDb();
// Map new:* folder keys to server-generated IDs
const newIdMap = new Map<string, string>();
for (const key of Object.keys(folders)) {
if (key.startsWith('new:')) {
newIdMap.set(key, generateSnowflake());
}
}
db.transaction((tx) => {
// Get existing folder IDs for this user
const existingFolders = tx.select({ id: schema.spaceFolders.id })
.from(schema.spaceFolders)
.where(eq(schema.spaceFolders.userId, userId))
.all();
const existingFolderIds = new Set(existingFolders.map(f => f.id));
// Determine which folders to keep (ones in the request, with resolved IDs)
const keepFolderIds = new Set<string>();
for (const [key, folder] of Object.entries(folders)) {
const resolvedId = newIdMap.get(key) ?? key;
keepFolderIds.add(resolvedId);
if (key.startsWith('new:')) {
// Create new folder
tx.insert(schema.spaceFolders).values({
id: resolvedId,
userId,
name: folder.name,
color: folder.color,
position: 0,
createdAt: Date.now(),
}).run();
} else if (existingFolderIds.has(key)) {
// Update existing folder
tx.update(schema.spaceFolders)
.set({ name: folder.name, color: folder.color })
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
.run();
}
// Clear and re-insert folder members with position
tx.delete(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, resolvedId))
.run();
for (let i = 0; i < folder.spaceIds.length; i++) {
const spaceId = folder.spaceIds[i];
if (!spaceId) continue;
tx.insert(schema.spaceFolderMembers).values({
folderId: resolvedId,
spaceId,
position: i,
}).run();
}
}
// Delete folders that are no longer in the request
for (const existingId of existingFolderIds) {
if (!keepFolderIds.has(existingId)) {
tx.delete(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, existingId))
.run();
tx.delete(schema.spaceFolders)
.where(and(eq(schema.spaceFolders.id, existingId), eq(schema.spaceFolders.userId, userId)))
.run();
}
}
// Replace new:* keys in items array with server-generated IDs
const finalItems: SpaceLayoutItem[] = items.map(item => {
if (item.t === 'f' && newIdMap.has(item.id)) {
return { t: 'f' as const, id: newIdMap.get(item.id)! };
}
return item;
});
// Upsert user_space_layout
const existing = tx.select().from(schema.userSpaceLayout)
.where(eq(schema.userSpaceLayout.userId, userId)).get();
if (existing) {
tx.update(schema.userSpaceLayout)
.set({ layout: JSON.stringify(finalItems), updatedAt: Date.now() })
.where(eq(schema.userSpaceLayout.userId, userId))
.run();
} else {
tx.insert(schema.userSpaceLayout).values({
userId,
layout: JSON.stringify(finalItems),
updatedAt: Date.now(),
}).run();
}
});
// Build response: fetch final state
const finalLayout = db.select().from(schema.userSpaceLayout)
.where(eq(schema.userSpaceLayout.userId, userId)).get();
const finalItems: SpaceLayoutItem[] = finalLayout ? JSON.parse(finalLayout.layout) : [];
const finalFolderRows = db.select().from(schema.spaceFolders)
.where(eq(schema.spaceFolders.userId, userId))
.orderBy(schema.spaceFolders.position)
.all();
const responseFolders: SpaceFolder[] = [];
for (const folder of finalFolderRows) {
const memberRows = db.select()
.from(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
.orderBy(schema.spaceFolderMembers.position)
.all();
responseFolders.push({
id: folder.id,
userId: folder.userId,
name: folder.name,
color: folder.color,
position: folder.position ?? 0,
spaceIds: memberRows.map(m => m.spaceId),
});
}
// Broadcast to user's other connections (multi-tab sync)
connectionManager.sendToUser(userId, {
type: 'space_layout_updated',
layout: finalItems,
folders: responseFolders,
});
return reply.code(200).send({ items: finalItems, folders: responseFolders });
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const db = getDb();