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
+9 -1
View File
@@ -15,6 +15,7 @@ import type {
DmChannel,
ServerEvent,
SpaceFolder,
SpaceLayoutItem,
ReadState,
ActiveCallInfo,
} from '@backspace/shared';
@@ -652,6 +653,7 @@ function buildReadyPayload(userId: string): {
spaces: SpaceWithChannelsAndMembers[];
dmChannels: DmChannel[];
folders: SpaceFolder[];
spaceLayout: SpaceLayoutItem[] | null;
voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
@@ -926,6 +928,7 @@ function buildReadyPayload(userId: string): {
const folderSpaceIds = db.select()
.from(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
.orderBy(schema.spaceFolderMembers.position)
.all()
.map(m => m.spaceId);
@@ -939,6 +942,11 @@ function buildReadyPayload(userId: string): {
});
}
// Get user space layout
const layoutRow = db.select().from(schema.userSpaceLayout)
.where(eq(schema.userSpaceLayout.userId, userId)).get();
const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null;
// Build voice states — tell the client who is currently in voice channels
// across all their spaces
const voiceStates: Record<string, string[]> = {};
@@ -1029,7 +1037,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId,
}));
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
return { user, spaces, dmChannels, folders, spaceLayout, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {