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
+18
View File
@@ -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.');
}
+7
View File
@@ -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'),