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:
@@ -473,9 +473,17 @@ CREATE TABLE space_folders (
|
|||||||
CREATE TABLE space_folder_members (
|
CREATE TABLE space_folder_members (
|
||||||
folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
|
folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
|
||||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER DEFAULT 0, -- ordering within folder
|
||||||
PRIMARY KEY (folder_id, space_id)
|
PRIMARY KEY (folder_id, space_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- User Space Layout (per-user sidebar ordering)
|
||||||
|
CREATE TABLE user_space_layout (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
layout TEXT NOT NULL DEFAULT '[]', -- JSON array of {t:'s',id} | {t:'f',id} items
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Instance Settings (singleton row, id=1)
|
-- Instance Settings (singleton row, id=1)
|
||||||
CREATE TABLE instance_settings (
|
CREATE TABLE instance_settings (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
@@ -544,6 +552,7 @@ POST /api/users/@me/verify-password (auth) { password } →
|
|||||||
POST /api/users/@me/change-password (auth) { currentPassword?, newPassword } → { token }
|
POST /api/users/@me/change-password (auth) { currentPassword?, newPassword } → { token }
|
||||||
DELETE /api/users/@me (auth) { password, username } → { success }
|
DELETE /api/users/@me (auth) { password, username } → { success }
|
||||||
GET /api/users/:id/mutuals (auth) ?homeUserId= → { mutualFriends[], mutualSpaces[] }
|
GET /api/users/:id/mutuals (auth) ?homeUserId= → { mutualFriends[], mutualSpaces[] }
|
||||||
|
PUT /api/users/@me/space-layout (auth) { items, folders } → { items, folders }
|
||||||
|
|
||||||
# Spaces
|
# Spaces
|
||||||
POST /api/spaces (auth) { name, icon?, banner?, avatarColor?, visibility?, description? } → { space }
|
POST /api/spaces (auth) { name, icon?, banner?, avatarColor?, visibility?, description? } → { space }
|
||||||
@@ -693,7 +702,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
|
|||||||
|
|
||||||
### Server → Client
|
### Server → Client
|
||||||
```
|
```
|
||||||
{ type: 'ready', user, spaces, dmChannels, folders, voiceStates, readStates, activeCalls }
|
{ type: 'ready', user, spaces, dmChannels, folders, spaceLayout, voiceStates, readStates, activeCalls }
|
||||||
{ type: 'pong' }
|
{ type: 'pong' }
|
||||||
|
|
||||||
# Server Messages
|
# Server Messages
|
||||||
@@ -751,6 +760,9 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
|
|||||||
{ type: 'join_request_received', request }
|
{ type: 'join_request_received', request }
|
||||||
{ type: 'join_request_accepted', request, space? }
|
{ type: 'join_request_accepted', request, space? }
|
||||||
{ type: 'join_request_declined', request }
|
{ type: 'join_request_declined', request }
|
||||||
|
|
||||||
|
# Space Layout
|
||||||
|
{ type: 'space_layout_updated', layout: SpaceLayoutItem[], folders: SpaceFolder[] }
|
||||||
```
|
```
|
||||||
|
|
||||||
## PERMISSION SYSTEM
|
## PERMISSION SYSTEM
|
||||||
|
|||||||
@@ -233,6 +233,24 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
// ─── Convert video channels to voice (video type removed) ─────────────────
|
// ─── Convert video channels to voice (video type removed) ─────────────────
|
||||||
migrateVideoChannels(db);
|
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.');
|
console.log('Migrations complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,10 +192,17 @@ export const spaceFolders = sqliteTable('space_folders', {
|
|||||||
export const spaceFolderMembers = sqliteTable('space_folder_members', {
|
export const spaceFolderMembers = sqliteTable('space_folder_members', {
|
||||||
folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }),
|
folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }),
|
||||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
position: integer('position').default(0),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.folderId, table.spaceId] }),
|
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', {
|
export const instanceSettings = sqliteTable('instance_settings', {
|
||||||
id: integer('id').primaryKey().default(1),
|
id: integer('id').primaryKey().default(1),
|
||||||
instanceName: text('instance_name').default('Backspace'),
|
instanceName: text('instance_name').default('Backspace'),
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
|
|||||||
.where(eq(schema.channels.spaceId, spaceId))
|
.where(eq(schema.channels.spaceId, spaceId))
|
||||||
.all();
|
.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()
|
const roles = db.select()
|
||||||
.from(schema.roles)
|
.from(schema.roles)
|
||||||
.where(eq(schema.roles.spaceId, spaceId))
|
.where(eq(schema.roles.spaceId, spaceId))
|
||||||
@@ -135,6 +148,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
|
|||||||
type: ch.type as Channel['type'],
|
type: ch.type as Channel['type'],
|
||||||
topic: ch.topic,
|
topic: ch.topic,
|
||||||
position: ch.position ?? 0,
|
position: ch.position ?? 0,
|
||||||
|
categoryId: ch.categoryId ?? null,
|
||||||
createdAt: ch.createdAt,
|
createdAt: ch.createdAt,
|
||||||
myPermissions: permissionsToString(chPerms),
|
myPermissions: permissionsToString(chPerms),
|
||||||
});
|
});
|
||||||
@@ -153,6 +167,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
|
|||||||
description: space.description ?? null,
|
description: space.description ?? null,
|
||||||
createdAt: space.createdAt,
|
createdAt: space.createdAt,
|
||||||
channels: visibleChannels,
|
channels: visibleChannels,
|
||||||
|
categories,
|
||||||
members,
|
members,
|
||||||
roles: roles.map(r => ({
|
roles: roles.map(r => ({
|
||||||
id: r.id,
|
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 userIds = [...new Set(banRows.map(b => b.userId))];
|
||||||
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
|
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
|
||||||
const allUserIds = [...new Set([...userIds, ...bannedByIds])];
|
const allUserIds = [...new Set([...userIds, ...bannedByIds].filter((id): id is string => id !== null))];
|
||||||
const users = db.select().from(schema.users)
|
const users = allUserIds.length > 0
|
||||||
.where(inArray(schema.users.id, allUserIds))
|
? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
|
||||||
.all();
|
: [];
|
||||||
const userMap = new Map(users.map(u => [u.id, u]));
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
|
||||||
const bans = banRows.map(b => {
|
const bans = banRows.map(b => {
|
||||||
const user = userMap.get(b.userId);
|
const user = userMap.get(b.userId);
|
||||||
const moderator = userMap.get(b.bannedBy);
|
const moderator = b.bannedBy ? userMap.get(b.bannedBy) : undefined;
|
||||||
return {
|
return {
|
||||||
spaceId: b.spaceId,
|
spaceId: b.spaceId,
|
||||||
userId: b.userId,
|
userId: b.userId,
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import crypto from 'crypto';
|
|||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||||
import { connectionManager } from '../ws/handler.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 { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
|
||||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
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);
|
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) => {
|
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
DmChannel,
|
DmChannel,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
SpaceFolder,
|
SpaceFolder,
|
||||||
|
SpaceLayoutItem,
|
||||||
ReadState,
|
ReadState,
|
||||||
ActiveCallInfo,
|
ActiveCallInfo,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
@@ -652,6 +653,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
spaces: SpaceWithChannelsAndMembers[];
|
spaces: SpaceWithChannelsAndMembers[];
|
||||||
dmChannels: DmChannel[];
|
dmChannels: DmChannel[];
|
||||||
folders: SpaceFolder[];
|
folders: SpaceFolder[];
|
||||||
|
spaceLayout: SpaceLayoutItem[] | null;
|
||||||
voiceStates: Record<string, string[]>;
|
voiceStates: Record<string, string[]>;
|
||||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||||
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
||||||
@@ -926,6 +928,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
const folderSpaceIds = db.select()
|
const folderSpaceIds = db.select()
|
||||||
.from(schema.spaceFolderMembers)
|
.from(schema.spaceFolderMembers)
|
||||||
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
|
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
|
||||||
|
.orderBy(schema.spaceFolderMembers.position)
|
||||||
.all()
|
.all()
|
||||||
.map(m => m.spaceId);
|
.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
|
// Build voice states — tell the client who is currently in voice channels
|
||||||
// across all their spaces
|
// across all their spaces
|
||||||
const voiceStates: Record<string, string[]> = {};
|
const voiceStates: Record<string, string[]> = {};
|
||||||
@@ -1029,7 +1037,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
lastReadMessageId: rs.lastReadMessageId,
|
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> {
|
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export interface SpaceFolder {
|
|||||||
spaceIds: string[];
|
spaceIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Space Layout Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SpaceLayoutItem =
|
||||||
|
| { t: 's'; id: string }
|
||||||
|
| { t: 'f'; id: string };
|
||||||
|
|
||||||
// ─── Channel Types ──────────────────────────────────────────────────────────
|
// ─── Channel Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
export type ChannelType = 'text' | 'voice';
|
||||||
@@ -265,7 +271,7 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
|
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
|
||||||
| { type: 'message_created'; message: MessageWithUser }
|
| { type: 'message_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
@@ -311,6 +317,7 @@ export type ServerEvent =
|
|||||||
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
|
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
|
||||||
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
||||||
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
||||||
|
| { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[] }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import type {
|
|||||||
ExploreSpace,
|
ExploreSpace,
|
||||||
JoinRequest,
|
JoinRequest,
|
||||||
Role,
|
Role,
|
||||||
|
SpaceLayoutItem,
|
||||||
|
SpaceFolder,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
@@ -64,6 +66,10 @@ export class BackspaceApiClient {
|
|||||||
getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>;
|
getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly spaceLayout: {
|
||||||
|
update: (data: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly spaces: {
|
readonly spaces: {
|
||||||
list: () => Promise<Space[]>;
|
list: () => Promise<Space[]>;
|
||||||
get: (id: string) => Promise<SpaceWithChannelsAndMembers>;
|
get: (id: string) => Promise<SpaceWithChannelsAndMembers>;
|
||||||
@@ -270,6 +276,11 @@ export class BackspaceApiClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.spaceLayout = {
|
||||||
|
update: (data) =>
|
||||||
|
request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>('PUT', '/users/@me/space-layout', data),
|
||||||
|
};
|
||||||
|
|
||||||
this.spaces = {
|
this.spaces = {
|
||||||
list: () => request<Space[]>('GET', '/spaces'),
|
list: () => request<Space[]>('GET', '/spaces'),
|
||||||
get: (id: string) => request<SpaceWithChannelsAndMembers>('GET', `/spaces/${id}`),
|
get: (id: string) => request<SpaceWithChannelsAndMembers>('GET', `/spaces/${id}`),
|
||||||
|
|||||||
@@ -2,15 +2,53 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
|
import type { TaggedSpace } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { Tooltip } from '../ui/Tooltip';
|
import { Tooltip } from '../ui/Tooltip';
|
||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
|
import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||||
|
|
||||||
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
|
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
|
||||||
|
|
||||||
|
// ─── Resolved layout types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ResolvedItem =
|
||||||
|
| { type: 'space'; space: TaggedSpace }
|
||||||
|
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[]; collapsed: boolean };
|
||||||
|
|
||||||
|
// ─── localStorage keys ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = 'backspace:collapsed-space-folders';
|
||||||
|
|
||||||
|
function getCollapsedFolders(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLLAPSED_KEY);
|
||||||
|
if (raw) return new Set(JSON.parse(raw));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCollapsedFolders(ids: Set<string>): void {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...ids]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Folder color presets ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FOLDER_COLORS = [
|
||||||
|
{ name: 'mint', value: '#86efac' },
|
||||||
|
{ name: 'peach', value: '#fbbf93' },
|
||||||
|
{ name: 'lavender', value: '#c4b5fd' },
|
||||||
|
{ name: 'sky', value: '#7dd3fc' },
|
||||||
|
{ name: 'amber', value: '#fcd34d' },
|
||||||
|
{ name: 'rose', value: '#fda4af' },
|
||||||
|
{ name: 'coral', value: '#fb7185' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── SidebarItem ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,9 +64,16 @@ interface SidebarItemProps {
|
|||||||
federationBadge?: boolean;
|
federationBadge?: boolean;
|
||||||
federationDisconnected?: boolean;
|
federationDisconnected?: boolean;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
|
draggable?: boolean;
|
||||||
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onDragOver?: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
onDrop?: (e: React.DragEvent) => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
dropIndicator?: 'before' | 'after' | 'merge' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) {
|
function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText, draggable, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, dropIndicator }: SidebarItemProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const firstLetter = name.charAt(0).toUpperCase();
|
const firstLetter = name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
@@ -106,7 +151,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
|
|||||||
);
|
);
|
||||||
|
|
||||||
const innerContent = (
|
const innerContent = (
|
||||||
<div className="relative">
|
<div className={`relative ${dropIndicator === 'merge' ? 'scale-110 ring-2 ring-accent-mint/60 rounded-[16px]' : ''} transition-transform duration-150`}>
|
||||||
{buttonContent}
|
{buttonContent}
|
||||||
{federationBadge && (
|
{federationBadge && (
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
|
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
|
||||||
@@ -124,11 +169,24 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center mb-1.5 w-full justify-center"
|
className={`relative flex items-center mb-1.5 w-full justify-center ${isDragging ? 'opacity-50' : ''}`}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
|
{/* Drop indicator lines */}
|
||||||
|
{dropIndicator === 'before' && (
|
||||||
|
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
{dropIndicator === 'after' && (
|
||||||
|
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pill Indicator */}
|
{/* Pill Indicator */}
|
||||||
{(type === 'space' || type === 'dm') && (
|
{(type === 'space' || type === 'dm') && (
|
||||||
<div className="absolute -left-0 w-2 h-10 flex items-center">
|
<div className="absolute -left-0 w-2 h-10 flex items-center">
|
||||||
@@ -149,22 +207,141 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstanceDivider({ label, disconnected }: { label: string; disconnected: boolean }) {
|
// ─── Mini space icon for collapsed folder ──────────────────────────────────
|
||||||
|
|
||||||
|
function MiniSpaceIcon({ space }: { space: TaggedSpace }) {
|
||||||
|
const icon = space.icon;
|
||||||
|
if (icon) {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={label} position="right" delay={300}>
|
<img
|
||||||
<div className="relative flex items-center justify-center w-full my-2">
|
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
||||||
<div className="absolute inset-x-5 h-[1px] bg-interactive-muted/30 rounded-full" />
|
alt=""
|
||||||
<div className={`relative z-[1] w-5 h-5 rounded-full bg-surface-base flex items-center justify-center ${
|
className="w-full h-full object-cover rounded-[3px]"
|
||||||
disconnected ? 'text-accent-amber' : 'text-txt-tertiary'
|
/>
|
||||||
}`}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const grad = getSpaceGradient(space.id, space.name, space.avatarColor);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full rounded-[3px] flex items-center justify-center text-white"
|
||||||
|
style={{ background: grad.gradient }}
|
||||||
|
>
|
||||||
|
<span className="text-[7px] font-bold leading-none">{space.name.charAt(0).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Collapsed folder icon (2×2 grid) ─────────────────────────────────────
|
||||||
|
|
||||||
|
function CollapsedFolderIcon({ spaces, color }: { spaces: TaggedSpace[]; color: string | null }) {
|
||||||
|
const display = spaces.slice(0, 4);
|
||||||
|
const remaining = spaces.length - 4;
|
||||||
|
return (
|
||||||
|
<div className="relative w-10 h-10 rounded-[13px] flex items-center justify-center overflow-hidden" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||||
|
{color && <div className="absolute inset-0 opacity-[0.12]" style={{ background: color }} />}
|
||||||
|
<div className="grid grid-cols-2 gap-[2px] w-[28px] h-[28px] relative z-[1]">
|
||||||
|
{display.map((s) => (
|
||||||
|
<div key={s.id} className="w-[13px] h-[13px]">
|
||||||
|
<MiniSpaceIcon space={s} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Fill empty cells */}
|
||||||
|
{display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="w-[13px] h-[13px] rounded-[3px] bg-white/[0.04]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{remaining > 0 && (
|
||||||
|
<div className="absolute bottom-0 right-0 text-[7px] font-bold text-txt-tertiary bg-surface-base rounded-tl-sm px-0.5">
|
||||||
|
+{remaining}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Folder context menu ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FolderContextMenu({ folder, x, y, onClose, onRename, onColorChange, onUngroup }: {
|
||||||
|
folder: SpaceFolder;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onColorChange: (color: string | null) => void;
|
||||||
|
onUngroup: () => void;
|
||||||
|
}) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const menuWidth = 200;
|
||||||
|
const menuHeight = 140;
|
||||||
|
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
||||||
|
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
|
||||||
|
style={{ left: clampedX, top: clampedY }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-txt-primary hover:bg-white/[0.06] transition-colors"
|
||||||
|
onClick={() => { onRename(); onClose(); }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||||
|
</svg>
|
||||||
|
Rename Folder
|
||||||
|
</button>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<p className="text-[11px] text-txt-tertiary mb-1.5">Folder Color</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
className={`w-5 h-5 rounded-full border-2 ${!folder.color ? 'border-white/40' : 'border-transparent'} bg-white/10`}
|
||||||
|
onClick={() => { onColorChange(null); onClose(); }}
|
||||||
|
title="Default"
|
||||||
|
/>
|
||||||
|
{FOLDER_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
className={`w-5 h-5 rounded-full border-2 ${folder.color === c.value ? 'border-white/40' : 'border-transparent'}`}
|
||||||
|
style={{ background: c.value }}
|
||||||
|
onClick={() => { onColorChange(c.value); onClose(); }}
|
||||||
|
title={c.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-[1px] bg-white/[0.06] mx-2 my-1" />
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-rose hover:bg-accent-rose/10 transition-colors"
|
||||||
|
onClick={() => { onUngroup(); onClose(); }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm12 6V8h-2v4H10v2h4v4h2v-4h4v-2h-4z" />
|
||||||
|
</svg>
|
||||||
|
Ungroup
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SpaceContextMenu ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) {
|
function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -293,6 +470,8 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── TransferOwnershipModal ───────────────────────────────────────────────
|
||||||
|
|
||||||
function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
|
function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
|
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
|
||||||
@@ -459,12 +638,17 @@ function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SpaceSidebar (main component) ────────────────────────────────────────
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||||
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
||||||
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
||||||
|
const folders = useSpaceStore((s) => s.folders);
|
||||||
|
const spaceLayout = useSpaceStore((s) => s.spaceLayout);
|
||||||
|
const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout);
|
||||||
const showDms = useUIStore((s) => s.showDms);
|
const showDms = useUIStore((s) => s.showDms);
|
||||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
@@ -476,8 +660,31 @@ export function SpaceSidebar() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Single context menu state
|
// Collapsed folders state
|
||||||
|
const [collapsedFolderIds, setCollapsedFolderIdsState] = useState(getCollapsedFolders);
|
||||||
|
const setCollapsedFolderIds = useCallback((ids: Set<string>) => {
|
||||||
|
setCollapsedFolderIdsState(ids);
|
||||||
|
setCollapsedFolders(ids);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFolderCollapse = useCallback((folderId: string) => {
|
||||||
|
setCollapsedFolderIdsState(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(folderId)) next.delete(folderId);
|
||||||
|
else next.add(folderId);
|
||||||
|
setCollapsedFolders(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Context menus
|
||||||
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
|
||||||
|
const [folderContextMenu, setFolderContextMenu] = useState<{ folder: SpaceFolder; x: number; y: number } | null>(null);
|
||||||
|
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// DnD state
|
||||||
|
const [dragState, setDragState] = useState<{ dragId: string; dragType: 'space' | 'folder'; sourceFolderId?: string } | null>(null);
|
||||||
|
const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after' | 'merge' } | null>(null);
|
||||||
|
|
||||||
const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => {
|
const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -486,20 +693,6 @@ export function SpaceSidebar() {
|
|||||||
|
|
||||||
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
||||||
|
|
||||||
// Group spaces by origin
|
|
||||||
const groupedSpaces = useMemo(() => {
|
|
||||||
const home = spaces.filter(s => !(s as any)._instanceOrigin);
|
|
||||||
const remoteMap = new Map<string, typeof spaces>();
|
|
||||||
for (const s of spaces) {
|
|
||||||
const origin = (s as any)._instanceOrigin;
|
|
||||||
if (!origin) continue;
|
|
||||||
const list = remoteMap.get(origin) || [];
|
|
||||||
list.push(s);
|
|
||||||
remoteMap.set(origin, list);
|
|
||||||
}
|
|
||||||
return { home, remoteGroups: Array.from(remoteMap.entries()) };
|
|
||||||
}, [spaces]);
|
|
||||||
|
|
||||||
// Set of disconnected origins
|
// Set of disconnected origins
|
||||||
const disconnectedOrigins = useMemo(() => {
|
const disconnectedOrigins = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -511,6 +704,64 @@ export function SpaceSidebar() {
|
|||||||
return set;
|
return set;
|
||||||
}, [instances]);
|
}, [instances]);
|
||||||
|
|
||||||
|
// Build space lookup map
|
||||||
|
const spaceMap = useMemo(() => {
|
||||||
|
const map = new Map<string, TaggedSpace>();
|
||||||
|
for (const s of spaces) map.set(s.id, s);
|
||||||
|
return map;
|
||||||
|
}, [spaces]);
|
||||||
|
|
||||||
|
// Build folder lookup map
|
||||||
|
const folderMap = useMemo(() => {
|
||||||
|
const map = new Map<string, SpaceFolder>();
|
||||||
|
for (const f of folders) map.set(f.id, f);
|
||||||
|
return map;
|
||||||
|
}, [folders]);
|
||||||
|
|
||||||
|
// Reconciled layout: merge spaceLayout with actual spaces and folders
|
||||||
|
const resolvedLayout = useMemo((): ResolvedItem[] => {
|
||||||
|
const memberSpaceIds = new Set(spaces.map(s => s.id));
|
||||||
|
const result: ResolvedItem[] = [];
|
||||||
|
const accountedSpaceIds = new Set<string>();
|
||||||
|
|
||||||
|
if (spaceLayout && spaceLayout.length > 0) {
|
||||||
|
for (const item of spaceLayout) {
|
||||||
|
if (item.t === 's') {
|
||||||
|
const space = spaceMap.get(item.id);
|
||||||
|
if (space) {
|
||||||
|
result.push({ type: 'space', space });
|
||||||
|
accountedSpaceIds.add(item.id);
|
||||||
|
}
|
||||||
|
} else if (item.t === 'f') {
|
||||||
|
const folder = folderMap.get(item.id);
|
||||||
|
if (folder) {
|
||||||
|
const folderSpaces = folder.spaceIds
|
||||||
|
.map(sid => spaceMap.get(sid))
|
||||||
|
.filter((s): s is TaggedSpace => !!s);
|
||||||
|
if (folderSpaces.length > 0) {
|
||||||
|
result.push({
|
||||||
|
type: 'folder',
|
||||||
|
folder,
|
||||||
|
spaces: folderSpaces,
|
||||||
|
collapsed: collapsedFolderIds.has(folder.id),
|
||||||
|
});
|
||||||
|
for (const s of folderSpaces) accountedSpaceIds.add(s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append any spaces not in the layout (newly joined, etc.)
|
||||||
|
for (const space of spaces) {
|
||||||
|
if (!accountedSpaceIds.has(space.id)) {
|
||||||
|
result.push({ type: 'space', space });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [spaceLayout, spaces, spaceMap, folderMap, collapsedFolderIds]);
|
||||||
|
|
||||||
// Compute which spaces have unread channels
|
// Compute which spaces have unread channels
|
||||||
const unreadSpaceIds = useMemo(() => {
|
const unreadSpaceIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -529,9 +780,9 @@ export function SpaceSidebar() {
|
|||||||
return false;
|
return false;
|
||||||
}, [unreadChannels, dmChannels]);
|
}, [unreadChannels, dmChannels]);
|
||||||
|
|
||||||
const handleSpaceClick = (spaceId: string) => {
|
const handleSpaceClick = useCallback((spaceId: string) => {
|
||||||
const space = spaces.find(s => s.id === spaceId);
|
const space = spaceMap.get(spaceId);
|
||||||
const origin = (space as any)?._instanceOrigin;
|
const origin = space?._instanceOrigin;
|
||||||
if (origin && disconnectedOrigins.has(origin)) {
|
if (origin && disconnectedOrigins.has(origin)) {
|
||||||
const inst = instances.find(i => i.origin === origin);
|
const inst = instances.find(i => i.origin === origin);
|
||||||
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
|
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
|
||||||
@@ -540,7 +791,7 @@ export function SpaceSidebar() {
|
|||||||
setCurrentSpace(spaceId);
|
setCurrentSpace(spaceId);
|
||||||
setShowDms(false);
|
setShowDms(false);
|
||||||
navigate(`/channels/${spaceId}`);
|
navigate(`/channels/${spaceId}`);
|
||||||
};
|
}, [spaceMap, disconnectedOrigins, instances, addToast, setCurrentSpace, setShowDms, navigate]);
|
||||||
|
|
||||||
const handleDmClick = () => {
|
const handleDmClick = () => {
|
||||||
setShowDms(true);
|
setShowDms(true);
|
||||||
@@ -555,6 +806,258 @@ export function SpaceSidebar() {
|
|||||||
navigate('/explore');
|
navigate('/explore');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── DnD handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: React.DragEvent, id: string, type: 'space' | 'folder', sourceFolderId?: string) => {
|
||||||
|
e.dataTransfer.setData('text/plain', id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
setDragState({ dragId: id, dragType: type, sourceFolderId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent, targetId: string, targetType: 'space' | 'folder') => {
|
||||||
|
if (!dragState) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const relY = e.clientY - rect.top;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
let position: 'before' | 'after' | 'merge';
|
||||||
|
if (targetType === 'folder' || dragState.dragType === 'space') {
|
||||||
|
// Space items: top 25% = before, middle 50% = merge, bottom 25% = after
|
||||||
|
if (relY < height * 0.25) {
|
||||||
|
position = 'before';
|
||||||
|
} else if (relY > height * 0.75) {
|
||||||
|
position = 'after';
|
||||||
|
} else {
|
||||||
|
// Merge zone: only if dragging a space onto another space or folder
|
||||||
|
if (dragState.dragType === 'space' && dragState.dragId !== targetId) {
|
||||||
|
position = 'merge';
|
||||||
|
} else {
|
||||||
|
position = relY < height * 0.5 ? 'before' : 'after';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Folder dragging: only before/after, no merge
|
||||||
|
position = relY < height * 0.5 ? 'before' : 'after';
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropIndicator({ targetId, position });
|
||||||
|
}, [dragState]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDragState(null);
|
||||||
|
setDropIndicator(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build layout items and folder payload from resolvedLayout for persistence
|
||||||
|
const buildLayoutPayload = useCallback((resolved: ResolvedItem[]) => {
|
||||||
|
const items: SpaceLayoutItem[] = [];
|
||||||
|
const folderPayload: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
|
||||||
|
|
||||||
|
for (const item of resolved) {
|
||||||
|
if (item.type === 'space') {
|
||||||
|
items.push({ t: 's', id: item.space.id });
|
||||||
|
} else {
|
||||||
|
items.push({ t: 'f', id: item.folder.id });
|
||||||
|
folderPayload[item.folder.id] = {
|
||||||
|
name: item.folder.name,
|
||||||
|
color: item.folder.color,
|
||||||
|
spaceIds: item.spaces.map(s => s.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, folderPayload };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistLayout = useCallback((resolved: ResolvedItem[]) => {
|
||||||
|
const { items, folderPayload } = buildLayoutPayload(resolved);
|
||||||
|
updateSpaceLayout(items, folderPayload);
|
||||||
|
}, [buildLayoutPayload, updateSpaceLayout]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragState || !dropIndicator) {
|
||||||
|
handleDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dragId, dragType, sourceFolderId } = dragState;
|
||||||
|
const { targetId, position } = dropIndicator;
|
||||||
|
|
||||||
|
// Don't drop on self
|
||||||
|
if (dragId === targetId && position !== 'merge') {
|
||||||
|
handleDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work with a mutable copy of the resolved layout
|
||||||
|
let newLayout = resolvedLayout.map(item => {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
return { ...item, spaces: [...item.spaces], folder: { ...item.folder } };
|
||||||
|
}
|
||||||
|
return { ...item };
|
||||||
|
}) as ResolvedItem[];
|
||||||
|
|
||||||
|
if (dragType === 'space') {
|
||||||
|
const dragSpace = spaceMap.get(dragId);
|
||||||
|
if (!dragSpace) { handleDragEnd(); return; }
|
||||||
|
|
||||||
|
// Remove from source
|
||||||
|
if (sourceFolderId) {
|
||||||
|
// Remove from folder
|
||||||
|
const folderItem = newLayout.find(i => i.type === 'folder' && i.folder.id === sourceFolderId) as (ResolvedItem & { type: 'folder' }) | undefined;
|
||||||
|
if (folderItem) {
|
||||||
|
folderItem.spaces = folderItem.spaces.filter(s => s.id !== dragId);
|
||||||
|
folderItem.folder = { ...folderItem.folder, spaceIds: folderItem.spaces.map(s => s.id) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove standalone
|
||||||
|
newLayout = newLayout.filter(item => !(item.type === 'space' && item.space.id === dragId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position === 'merge') {
|
||||||
|
// Find the target
|
||||||
|
const targetIdx = newLayout.findIndex(item =>
|
||||||
|
(item.type === 'space' && item.space.id === targetId) ||
|
||||||
|
(item.type === 'folder' && item.folder.id === targetId)
|
||||||
|
);
|
||||||
|
if (targetIdx === -1) { handleDragEnd(); return; }
|
||||||
|
|
||||||
|
const targetItem = newLayout[targetIdx];
|
||||||
|
if (!targetItem) { handleDragEnd(); return; }
|
||||||
|
|
||||||
|
if (targetItem.type === 'space') {
|
||||||
|
// Create new folder with both spaces
|
||||||
|
const tempId = `new:${Date.now()}`;
|
||||||
|
const newFolder: ResolvedItem = {
|
||||||
|
type: 'folder',
|
||||||
|
folder: {
|
||||||
|
id: tempId,
|
||||||
|
userId: '',
|
||||||
|
name: null,
|
||||||
|
color: null,
|
||||||
|
position: 0,
|
||||||
|
spaceIds: [targetItem.space.id, dragSpace.id],
|
||||||
|
},
|
||||||
|
spaces: [targetItem.space, dragSpace],
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
newLayout[targetIdx] = newFolder;
|
||||||
|
} else if (targetItem.type === 'folder') {
|
||||||
|
// Add to existing folder
|
||||||
|
targetItem.spaces.push(dragSpace);
|
||||||
|
targetItem.folder = { ...targetItem.folder, spaceIds: targetItem.spaces.map(s => s.id) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reorder: insert before or after target
|
||||||
|
const targetIdx = newLayout.findIndex(item =>
|
||||||
|
(item.type === 'space' && item.space.id === targetId) ||
|
||||||
|
(item.type === 'folder' && item.folder.id === targetId)
|
||||||
|
);
|
||||||
|
if (targetIdx === -1) { handleDragEnd(); return; }
|
||||||
|
|
||||||
|
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
|
||||||
|
const newItem: ResolvedItem = { type: 'space', space: dragSpace };
|
||||||
|
newLayout.splice(insertIdx, 0, newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dissolve folders with < 2 members
|
||||||
|
newLayout = newLayout.flatMap(item => {
|
||||||
|
if (item.type === 'folder' && item.spaces.length < 2) {
|
||||||
|
if (item.spaces.length === 1 && item.spaces[0]) {
|
||||||
|
return [{ type: 'space' as const, space: item.spaces[0] }];
|
||||||
|
}
|
||||||
|
return []; // 0 members, remove entirely
|
||||||
|
}
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (dragType === 'folder') {
|
||||||
|
// Remove the folder from its current position
|
||||||
|
const dragIdx = newLayout.findIndex(i => i.type === 'folder' && i.folder.id === dragId);
|
||||||
|
if (dragIdx === -1) { handleDragEnd(); return; }
|
||||||
|
const dragItem = newLayout.splice(dragIdx, 1)[0];
|
||||||
|
if (!dragItem) { handleDragEnd(); return; }
|
||||||
|
|
||||||
|
// Insert at target position
|
||||||
|
const targetIdx = newLayout.findIndex(item =>
|
||||||
|
(item.type === 'space' && item.space.id === targetId) ||
|
||||||
|
(item.type === 'folder' && item.folder.id === targetId)
|
||||||
|
);
|
||||||
|
if (targetIdx === -1) {
|
||||||
|
newLayout.push(dragItem);
|
||||||
|
} else {
|
||||||
|
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
|
||||||
|
newLayout.splice(insertIdx, 0, dragItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persistLayout(newLayout);
|
||||||
|
handleDragEnd();
|
||||||
|
}, [dragState, dropIndicator, resolvedLayout, spaceMap, handleDragEnd, persistLayout]);
|
||||||
|
|
||||||
|
// ─── Folder actions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleFolderRename = useCallback((folderId: string, name: string) => {
|
||||||
|
const newLayout = resolvedLayout.map(item => {
|
||||||
|
if (item.type === 'folder' && item.folder.id === folderId) {
|
||||||
|
return { ...item, folder: { ...item.folder, name: name.trim() || null } };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
persistLayout(newLayout);
|
||||||
|
setRenamingFolderId(null);
|
||||||
|
}, [resolvedLayout, persistLayout]);
|
||||||
|
|
||||||
|
const handleFolderColorChange = useCallback((folderId: string, color: string | null) => {
|
||||||
|
const newLayout = resolvedLayout.map(item => {
|
||||||
|
if (item.type === 'folder' && item.folder.id === folderId) {
|
||||||
|
return { ...item, folder: { ...item.folder, color } };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
persistLayout(newLayout);
|
||||||
|
}, [resolvedLayout, persistLayout]);
|
||||||
|
|
||||||
|
const handleUngroup = useCallback((folderId: string) => {
|
||||||
|
const newLayout = resolvedLayout.flatMap(item => {
|
||||||
|
if (item.type === 'folder' && item.folder.id === folderId) {
|
||||||
|
return item.spaces.map(s => ({ type: 'space' as const, space: s }));
|
||||||
|
}
|
||||||
|
return [item];
|
||||||
|
});
|
||||||
|
persistLayout(newLayout);
|
||||||
|
}, [resolvedLayout, persistLayout]);
|
||||||
|
|
||||||
|
// ─── Helpers for rendering ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const getFederationInfo = useCallback((space: TaggedSpace) => {
|
||||||
|
const origin = space._instanceOrigin;
|
||||||
|
const isFederated = !!origin;
|
||||||
|
const isDimmed = isFederated && disconnectedOrigins.has(origin);
|
||||||
|
let tooltipText = space.name;
|
||||||
|
if (isFederated) {
|
||||||
|
try {
|
||||||
|
const hostLabel = new URL(origin).host;
|
||||||
|
tooltipText = `${space.name} \u00b7 ${hostLabel}`;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return { isFederated, isDimmed, tooltipText };
|
||||||
|
}, [disconnectedOrigins]);
|
||||||
|
|
||||||
|
// Check if a folder has any unread spaces
|
||||||
|
const folderHasUnread = useCallback((folderSpaces: TaggedSpace[]) => {
|
||||||
|
return folderSpaces.some(s => unreadSpaceIds.has(s.id));
|
||||||
|
}, [unreadSpaceIds]);
|
||||||
|
|
||||||
|
// Check if folder has any active space
|
||||||
|
const folderHasActive = useCallback((folderSpaces: TaggedSpace[]) => {
|
||||||
|
return currentSpaceId ? folderSpaces.some(s => s.id === currentSpaceId) : false;
|
||||||
|
}, [currentSpaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }}>
|
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
@@ -568,35 +1071,148 @@ export function SpaceSidebar() {
|
|||||||
|
|
||||||
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
||||||
|
|
||||||
{/* Home spaces */}
|
{/* Unified space list (ordered by user layout) */}
|
||||||
{groupedSpaces.home.map((space) => (
|
{resolvedLayout.map((item) => {
|
||||||
<SidebarItem
|
if (item.type === 'space') {
|
||||||
key={space.id}
|
const { isFederated, isDimmed, tooltipText } = getFederationInfo(item.space);
|
||||||
id={space.id}
|
|
||||||
name={space.name}
|
|
||||||
icon={space.icon}
|
|
||||||
avatarColor={space.avatarColor}
|
|
||||||
active={currentSpaceId === space.id}
|
|
||||||
onClick={() => handleSpaceClick(space.id)}
|
|
||||||
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
|
||||||
hasUnread={unreadSpaceIds.has(space.id)}
|
|
||||||
tooltipText={space.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Remote instance groups */}
|
|
||||||
{groupedSpaces.remoteGroups.map(([origin, groupSpaces]) => {
|
|
||||||
const inst = instances.find(i => i.origin === origin);
|
|
||||||
const hostLabel = (() => { try { return new URL(origin).host; } catch { return '?'; } })();
|
|
||||||
const label = inst?.label || hostLabel;
|
|
||||||
const isDimmed = disconnectedOrigins.has(origin);
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={origin}>
|
<SidebarItem
|
||||||
<InstanceDivider
|
key={item.space.id}
|
||||||
label={isDimmed ? `${label} (disconnected)` : label}
|
id={item.space.id}
|
||||||
disconnected={isDimmed}
|
name={item.space.name}
|
||||||
|
icon={item.space.icon}
|
||||||
|
avatarColor={item.space.avatarColor}
|
||||||
|
active={currentSpaceId === item.space.id}
|
||||||
|
onClick={() => handleSpaceClick(item.space.id)}
|
||||||
|
onContextMenu={(e) => handleSpaceContextMenu(item.space.id, e)}
|
||||||
|
hasUnread={unreadSpaceIds.has(item.space.id)}
|
||||||
|
dimmed={isDimmed}
|
||||||
|
federationBadge={isFederated}
|
||||||
|
federationDisconnected={isDimmed}
|
||||||
|
tooltipText={tooltipText}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, item.space.id, 'space')}
|
||||||
|
onDragOver={(e) => handleDragOver(e, item.space.id, 'space')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
isDragging={dragState?.dragType === 'space' && dragState.dragId === item.space.id}
|
||||||
|
dropIndicator={dropIndicator?.targetId === item.space.id ? dropIndicator.position : null}
|
||||||
/>
|
/>
|
||||||
{groupSpaces.map((space) => (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder
|
||||||
|
const { folder, spaces: folderSpaces, collapsed } = item;
|
||||||
|
const isActive = folderHasActive(folderSpaces);
|
||||||
|
const hasUnread = folderHasUnread(folderSpaces);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
// Collapsed folder — single icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`folder-${folder.id}`}
|
||||||
|
className={`relative flex items-center mb-1.5 w-full justify-center ${
|
||||||
|
dragState?.dragType === 'folder' && dragState.dragId === folder.id ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
|
||||||
|
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drop indicators */}
|
||||||
|
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'before' && (
|
||||||
|
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'after' && (
|
||||||
|
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'merge' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
|
<div className="w-12 h-12 rounded-[16px] ring-2 ring-accent-mint/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pill indicator */}
|
||||||
|
<div className="absolute -left-0 w-2 h-10 flex items-center">
|
||||||
|
<div className={`bg-white rounded-r-full transition-all duration-200 origin-left ${
|
||||||
|
isActive ? 'h-8' : hasUnread ? 'h-2' : 'h-2 scale-0'
|
||||||
|
} w-1`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip content={folder.name || `Folder (${folderSpaces.length})`} position="right" delay={300}>
|
||||||
|
<button onClick={() => toggleFolderCollapse(folder.id)}>
|
||||||
|
<CollapsedFolderIcon spaces={folderSpaces} color={folder.color} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded folder
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`folder-${folder.id}`}
|
||||||
|
className="w-full flex flex-col items-center"
|
||||||
|
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Top separator with folder toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-full my-1 cursor-pointer"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drop indicator for folder header */}
|
||||||
|
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'before' && (
|
||||||
|
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
<Tooltip content={folder.name || `Folder (${folderSpaces.length})`} position="right" delay={300}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFolderCollapse(folder.id)}
|
||||||
|
className="relative flex items-center justify-center w-12"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-[2px] rounded-full" style={{ background: folder.color || 'rgba(255,255,255,0.08)' }} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder rename input */}
|
||||||
|
{renamingFolderId === folder.id && (
|
||||||
|
<div className="w-full px-3 mb-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-surface-input text-[10px] text-txt-primary rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-accent-mint/40"
|
||||||
|
defaultValue={folder.name || ''}
|
||||||
|
onBlur={(e) => handleFolderRename(folder.id, e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleFolderRename(folder.id, e.currentTarget.value);
|
||||||
|
if (e.key === 'Escape') setRenamingFolderId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folder member spaces */}
|
||||||
|
<div className="relative w-full flex flex-col items-center">
|
||||||
|
{/* Left accent line */}
|
||||||
|
<div
|
||||||
|
className="absolute left-[10px] top-0 bottom-0 w-[2px] rounded-full"
|
||||||
|
style={{ background: folder.color || 'rgba(255,255,255,0.06)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{folderSpaces.map((space) => {
|
||||||
|
const { isFederated, isDimmed, tooltipText } = getFederationInfo(space);
|
||||||
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={space.id}
|
key={space.id}
|
||||||
id={space.id}
|
id={space.id}
|
||||||
@@ -608,12 +1224,29 @@ export function SpaceSidebar() {
|
|||||||
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
||||||
hasUnread={unreadSpaceIds.has(space.id)}
|
hasUnread={unreadSpaceIds.has(space.id)}
|
||||||
dimmed={isDimmed}
|
dimmed={isDimmed}
|
||||||
federationBadge
|
federationBadge={isFederated}
|
||||||
federationDisconnected={isDimmed}
|
federationDisconnected={isDimmed}
|
||||||
tooltipText={`${space.name} \u00b7 ${hostLabel}`}
|
tooltipText={tooltipText}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, space.id, 'space', folder.id)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, space.id, 'space')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
isDragging={dragState?.dragType === 'space' && dragState.dragId === space.id}
|
||||||
|
dropIndicator={dropIndicator?.targetId === space.id ? dropIndicator.position : null}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
</React.Fragment>
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom separator */}
|
||||||
|
<div className="flex items-center justify-center w-full my-1">
|
||||||
|
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'after' && (
|
||||||
|
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
||||||
|
)}
|
||||||
|
<div className="w-8 h-[2px] rounded-full" style={{ background: folder.color || 'rgba(255,255,255,0.08)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -654,6 +1287,18 @@ export function SpaceSidebar() {
|
|||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{folderContextMenu && (
|
||||||
|
<FolderContextMenu
|
||||||
|
folder={folderContextMenu.folder}
|
||||||
|
x={folderContextMenu.x}
|
||||||
|
y={folderContextMenu.y}
|
||||||
|
onClose={() => setFolderContextMenu(null)}
|
||||||
|
onRename={() => setRenamingFolderId(folderContextMenu.folder.id)}
|
||||||
|
onColorChange={(color) => handleFolderColorChange(folderContextMenu.folder.id, color)}
|
||||||
|
onUngroup={() => handleUngroup(folderContextMenu.folder.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
populateFromReady(origin, event.spaces, event.folders, event.dmChannels);
|
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout);
|
||||||
|
|
||||||
// Cache authoritative identity for this origin (federation-safe)
|
// Cache authoritative identity for this origin (federation-safe)
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
@@ -697,6 +697,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'space_layout_updated': {
|
||||||
|
if (!isHome) break;
|
||||||
|
const { setSpaceLayout } = useSpaceStore.getState();
|
||||||
|
setSpaceLayout(event.layout);
|
||||||
|
useSpaceStore.setState({ folders: event.folders });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Join request events (home-only) ────────────────────────────────
|
// ─── Join request events (home-only) ────────────────────────────────
|
||||||
|
|
||||||
case 'join_request_received': {
|
case 'join_request_received': {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
|
import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, SpaceLayoutItem, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
|
||||||
import { api, BackspaceApiClient } from '../api/client';
|
import { api, BackspaceApiClient } from '../api/client';
|
||||||
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
||||||
import { isSelf } from '../utils/identity';
|
import { isSelf } from '../utils/identity';
|
||||||
@@ -30,6 +30,7 @@ interface SpaceState {
|
|||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
folders: SpaceFolder[];
|
folders: SpaceFolder[];
|
||||||
|
spaceLayout: SpaceLayoutItem[] | null;
|
||||||
dmChannels: DmChannel[];
|
dmChannels: DmChannel[];
|
||||||
channelToSpaceMap: Map<string, string>;
|
channelToSpaceMap: Map<string, string>;
|
||||||
channelLastMessageIds: Map<string, string>;
|
channelLastMessageIds: Map<string, string>;
|
||||||
@@ -72,7 +73,9 @@ interface SpaceState {
|
|||||||
updateUserEverywhere: (user: User) => void;
|
updateUserEverywhere: (user: User) => void;
|
||||||
addMember: (member: MemberWithUser) => void;
|
addMember: (member: MemberWithUser) => void;
|
||||||
removeMember: (userId: string) => void;
|
removeMember: (userId: string) => void;
|
||||||
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void;
|
setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void;
|
||||||
|
updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>) => Promise<void>;
|
||||||
|
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => void;
|
||||||
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
|
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
|
||||||
removeInstanceSpaces: (origin: string) => void;
|
removeInstanceSpaces: (origin: string) => void;
|
||||||
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<void>;
|
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<void>;
|
||||||
@@ -87,6 +90,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
members: [],
|
members: [],
|
||||||
roles: [],
|
roles: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
|
spaceLayout: null,
|
||||||
dmChannels: [],
|
dmChannels: [],
|
||||||
channelToSpaceMap: new Map(),
|
channelToSpaceMap: new Map(),
|
||||||
channelLastMessageIds: new Map(),
|
channelLastMessageIds: new Map(),
|
||||||
@@ -415,7 +419,22 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => {
|
setSpaceLayout: (layout) => set({ spaceLayout: layout }),
|
||||||
|
|
||||||
|
updateSpaceLayout: async (items, folders) => {
|
||||||
|
// Optimistic: apply the layout immediately
|
||||||
|
set({ spaceLayout: items });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.spaceLayout.update({ items, folders });
|
||||||
|
// Server may have resolved new:* IDs
|
||||||
|
set({ spaceLayout: result.items, folders: result.folders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save space layout:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => {
|
||||||
const isHome = !origin;
|
const isHome = !origin;
|
||||||
|
|
||||||
// Tag all incoming servers with their instance origin
|
// Tag all incoming servers with their instance origin
|
||||||
@@ -533,9 +552,12 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
categoryOriginMap,
|
categoryOriginMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set folders from home origin
|
// Only set folders and layout from home origin
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
update.folders = folders || [];
|
update.folders = folders || [];
|
||||||
|
if (spaceLayout !== undefined) {
|
||||||
|
update.spaceLayout = spaceLayout ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set(update as any);
|
set(update as any);
|
||||||
|
|||||||
Reference in New Issue
Block a user