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:
@@ -233,6 +233,24 @@ export function runMigrations(db: Database.Database): void {
|
||||
// ─── Convert video channels to voice (video type removed) ─────────────────
|
||||
migrateVideoChannels(db);
|
||||
|
||||
// ─── Ensure user_space_layout table exists ────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_space_layout (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Add position column to space_folder_members ──────────────────────────
|
||||
{
|
||||
const sfmColumns = db.pragma('table_info(space_folder_members)') as { name: string }[];
|
||||
if (!sfmColumns.some(c => c.name === 'position')) {
|
||||
db.exec('ALTER TABLE space_folder_members ADD COLUMN position INTEGER DEFAULT 0');
|
||||
console.log('Migrating: Added position column to space_folder_members');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
|
||||
|
||||
@@ -192,10 +192,17 @@ export const spaceFolders = sqliteTable('space_folders', {
|
||||
export const spaceFolderMembers = sqliteTable('space_folder_members', {
|
||||
folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
position: integer('position').default(0),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.folderId, table.spaceId] }),
|
||||
}));
|
||||
|
||||
export const userSpaceLayout = sqliteTable('user_space_layout', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
layout: text('layout').notNull().default('[]'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const instanceSettings = sqliteTable('instance_settings', {
|
||||
id: integer('id').primaryKey().default(1),
|
||||
instanceName: text('instance_name').default('Backspace'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user