feat: channel categories with drag-and-drop reordering
Add channel categories (named groups) with full CRUD, collapsible headers with unread indicators, and native HTML5 drag-and-drop for reordering channels within/between categories and reordering categories themselves. - Schema: channel_categories table, category_id column on channels - Server: category CRUD endpoints, batch channel-layout reorder endpoint - WebSocket: categories in ready payload, category_created/updated/deleted and channel_layout_updated events with per-user VIEW_CHANNEL filtering - Frontend: dynamic category-based sidebar layout replacing hardcoded Text/Voice sections, collapse state persisted to localStorage, category selector in CreateChannel modal, federation-aware store
This commit is contained in:
@@ -111,6 +111,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'avatar_color', type: 'TEXT' },
|
{ name: 'avatar_color', type: 'TEXT' },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'channels',
|
||||||
|
columns: [
|
||||||
|
{ name: 'category_id', type: 'TEXT' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -168,6 +174,17 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Ensure channel_categories table exists (idempotent)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_categories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Ensure voice_restrictions table exists (idempotent)
|
// Ensure voice_restrictions table exists (idempotent)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS voice_restrictions (
|
CREATE TABLE IF NOT EXISTS voice_restrictions (
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export const spaceMembers = sqliteTable('space_members', {
|
|||||||
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const channelCategories = sqliteTable('channel_categories', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
position: integer('position').default(0),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const channels = sqliteTable('channels', {
|
export const channels = sqliteTable('channels', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
@@ -49,6 +57,7 @@ export const channels = sqliteTable('channels', {
|
|||||||
type: text('type').notNull(),
|
type: text('type').notNull(),
|
||||||
topic: text('topic'),
|
topic: text('topic'),
|
||||||
position: integer('position').default(0),
|
position: integer('position').default(0),
|
||||||
|
categoryId: text('category_id'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
CreateChannelRequest,
|
CreateChannelRequest,
|
||||||
UpdateChannelRequest,
|
UpdateChannelRequest,
|
||||||
Channel,
|
Channel,
|
||||||
|
ChannelCategory,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
|
||||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||||
@@ -21,6 +22,17 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
|||||||
type: row.type as Channel['type'],
|
type: row.type as Channel['type'],
|
||||||
topic: row.topic,
|
topic: row.topic,
|
||||||
position: row.position ?? 0,
|
position: row.position ?? 0,
|
||||||
|
categoryId: row.categoryId ?? null,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
name: row.name,
|
||||||
|
position: row.position ?? 0,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +108,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { name, type, topic } = request.body;
|
const { name, type, topic, categoryId } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||||
@@ -121,6 +133,18 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate categoryId if provided
|
||||||
|
let validCategoryId: string | null = null;
|
||||||
|
if (categoryId) {
|
||||||
|
const cat = db.select().from(schema.channelCategories)
|
||||||
|
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id)))
|
||||||
|
.get();
|
||||||
|
if (!cat) {
|
||||||
|
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
validCategoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
// Get max position for ordering
|
// Get max position for ordering
|
||||||
const existingChannels = db.select()
|
const existingChannels = db.select()
|
||||||
.from(schema.channels)
|
.from(schema.channels)
|
||||||
@@ -139,6 +163,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
type,
|
type,
|
||||||
topic: topic?.trim() || null,
|
topic: topic?.trim() || null,
|
||||||
position: maxPosition + 1,
|
position: maxPosition + 1,
|
||||||
|
categoryId: validCategoryId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -172,7 +197,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { name, topic, position } = request.body;
|
const { name, topic, position, categoryId } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
||||||
@@ -206,6 +231,20 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updates.position = position;
|
updates.position = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (categoryId !== undefined) {
|
||||||
|
if (categoryId === null) {
|
||||||
|
updates.categoryId = null;
|
||||||
|
} else {
|
||||||
|
const cat = db.select().from(schema.channelCategories)
|
||||||
|
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId)))
|
||||||
|
.get();
|
||||||
|
if (!cat) {
|
||||||
|
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updates.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
@@ -394,4 +433,281 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Channel Category Endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /api/spaces/:id/categories - Create a category
|
||||||
|
app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { name } = request.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||||
|
if (!space) {
|
||||||
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
return reply.code(400).send({ error: 'Category name is required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (trimmedName.length > 100) {
|
||||||
|
return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.spaceId, id))
|
||||||
|
.all();
|
||||||
|
const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1);
|
||||||
|
|
||||||
|
const categoryId = generateSnowflake();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
db.insert(schema.channelCategories).values({
|
||||||
|
id: categoryId,
|
||||||
|
spaceId: id,
|
||||||
|
name: trimmedName,
|
||||||
|
position: maxPos + 1,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const category = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.id, categoryId)).get();
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryData = rowToCategory(category);
|
||||||
|
connectionManager.sendToSpace(id, {
|
||||||
|
type: 'category_created',
|
||||||
|
category: categoryData,
|
||||||
|
spaceId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send(categoryData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/categories/:id - Update a category
|
||||||
|
app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { name, position } = request.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const category = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.id, id)).get();
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<typeof schema.channelCategories.$inferInsert> = {};
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName || trimmedName.length > 100) {
|
||||||
|
return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updates.name = trimmedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position !== undefined) {
|
||||||
|
if (typeof position !== 'number' || position < 0) {
|
||||||
|
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
|
||||||
|
}
|
||||||
|
updates.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.channelCategories).set(updates)
|
||||||
|
.where(eq(schema.channelCategories.id, id)).run();
|
||||||
|
|
||||||
|
const updated = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.id, id)).get();
|
||||||
|
if (!updated) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryData = rowToCategory(updated);
|
||||||
|
connectionManager.sendToSpace(category.spaceId, {
|
||||||
|
type: 'category_updated',
|
||||||
|
category: categoryData,
|
||||||
|
spaceId: category.spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(categoryData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/categories/:id - Delete a category
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/categories/:id', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const category = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.id, id)).get();
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = category.spaceId;
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
// Null out categoryId on all channels in this category
|
||||||
|
tx.update(schema.channels).set({ categoryId: null })
|
||||||
|
.where(eq(schema.channels.categoryId, id)).run();
|
||||||
|
// Delete the category
|
||||||
|
tx.delete(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.id, id)).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast category deletion
|
||||||
|
connectionManager.sendToSpace(spaceId, {
|
||||||
|
type: 'category_deleted',
|
||||||
|
categoryId: id,
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also broadcast updated layout so channels reflect null categoryId
|
||||||
|
broadcastChannelLayout(spaceId);
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories
|
||||||
|
app.patch<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: {
|
||||||
|
channels: Array<{ id: string; position: number; categoryId: string | null }>;
|
||||||
|
categories: Array<{ id: string; position: number }>;
|
||||||
|
};
|
||||||
|
}>('/api/spaces/:id/channel-layout', {
|
||||||
|
preHandler: authenticate,
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { channels: channelUpdates, categories: categoryUpdates } = request.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||||
|
if (!space) {
|
||||||
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) {
|
||||||
|
return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all channel IDs belong to this space
|
||||||
|
const spaceChannels = db.select().from(schema.channels)
|
||||||
|
.where(eq(schema.channels.spaceId, id)).all();
|
||||||
|
const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id));
|
||||||
|
for (const ch of channelUpdates) {
|
||||||
|
if (!spaceChannelIds.has(ch.id)) {
|
||||||
|
return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (typeof ch.position !== 'number' || ch.position < 0) {
|
||||||
|
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all category IDs belong to this space
|
||||||
|
const spaceCategories = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.spaceId, id)).all();
|
||||||
|
const spaceCategoryIds = new Set(spaceCategories.map(c => c.id));
|
||||||
|
for (const cat of categoryUpdates) {
|
||||||
|
if (!spaceCategoryIds.has(cat.id)) {
|
||||||
|
return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (typeof cat.position !== 'number' || cat.position < 0) {
|
||||||
|
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate category references in channels
|
||||||
|
for (const ch of channelUpdates) {
|
||||||
|
if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) {
|
||||||
|
return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply all updates in a transaction
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const ch of channelUpdates) {
|
||||||
|
tx.update(schema.channels)
|
||||||
|
.set({ position: ch.position, categoryId: ch.categoryId })
|
||||||
|
.where(eq(schema.channels.id, ch.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
for (const cat of categoryUpdates) {
|
||||||
|
tx.update(schema.channelCategories)
|
||||||
|
.set({ position: cat.position })
|
||||||
|
.where(eq(schema.channelCategories.id, cat.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast the updated layout to all space members with per-user channel filtering
|
||||||
|
broadcastChannelLayout(id);
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast updated channel layout to all space members.
|
||||||
|
* Each user gets only the channels they can view (VIEW_CHANNEL check).
|
||||||
|
*/
|
||||||
|
function broadcastChannelLayout(spaceId: string): void {
|
||||||
|
const db = getDb();
|
||||||
|
const allChannels = db.select().from(schema.channels)
|
||||||
|
.where(eq(schema.channels.spaceId, spaceId)).all();
|
||||||
|
const allCategories = db.select().from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.spaceId, spaceId)).all();
|
||||||
|
|
||||||
|
const categoryData = allCategories.map(rowToCategory);
|
||||||
|
|
||||||
|
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
||||||
|
if (!spaceIds.has(spaceId)) continue;
|
||||||
|
|
||||||
|
const visibleChannels: Channel[] = [];
|
||||||
|
for (const ch of allChannels) {
|
||||||
|
const perms = computePermissions(userId, spaceId, ch.id);
|
||||||
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||||
|
visibleChannels.push({
|
||||||
|
...rowToChannel(ch),
|
||||||
|
myPermissions: permissionsToString(perms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToUser(userId, {
|
||||||
|
type: 'channel_layout_updated',
|
||||||
|
spaceId,
|
||||||
|
channels: visibleChannels,
|
||||||
|
categories: categoryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
UpdateMemberRequest,
|
UpdateMemberRequest,
|
||||||
Space,
|
Space,
|
||||||
Channel,
|
Channel,
|
||||||
|
ChannelCategory,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
SpaceWithChannelsAndMembers,
|
SpaceWithChannelsAndMembers,
|
||||||
Role,
|
Role,
|
||||||
@@ -45,6 +46,7 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
|||||||
type: row.type as Channel['type'],
|
type: row.type as Channel['type'],
|
||||||
topic: row.topic,
|
topic: row.topic,
|
||||||
position: row.position ?? 0,
|
position: row.position ?? 0,
|
||||||
|
categoryId: row.categoryId ?? null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,6 +241,19 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
|
|
||||||
|
// Fetch categories for this space
|
||||||
|
const categoryRows = db.select()
|
||||||
|
.from(schema.channelCategories)
|
||||||
|
.where(eq(schema.channelCategories.spaceId, id))
|
||||||
|
.all();
|
||||||
|
const categories: ChannelCategory[] = categoryRows.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
spaceId: c.spaceId,
|
||||||
|
name: c.name,
|
||||||
|
position: c.position ?? 0,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
// Compute space-level permissions for the requesting user
|
// Compute space-level permissions for the requesting user
|
||||||
const spacePerms = computePermissions(request.userId, id);
|
const spacePerms = computePermissions(request.userId, id);
|
||||||
|
|
||||||
@@ -259,6 +274,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const result: SpaceWithChannelsAndMembers = {
|
const result: SpaceWithChannelsAndMembers = {
|
||||||
...rowToSpace(server),
|
...rowToSpace(server),
|
||||||
channels: visibleChannels,
|
channels: visibleChannels,
|
||||||
|
categories,
|
||||||
members,
|
members,
|
||||||
roles: roles.map(r => ({
|
roles: roles.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SpaceWithChannelsAndMembers,
|
SpaceWithChannelsAndMembers,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
Channel,
|
Channel,
|
||||||
|
ChannelCategory,
|
||||||
DmChannel,
|
DmChannel,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
SpaceFolder,
|
SpaceFolder,
|
||||||
@@ -694,6 +695,24 @@ function buildReadyPayload(userId: string): {
|
|||||||
arr.push(ch);
|
arr.push(ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch: all categories for all spaces (1 query instead of N)
|
||||||
|
const allCategories = batchInArray(
|
||||||
|
spaceIds,
|
||||||
|
ids => db.select().from(schema.channelCategories).where(inArray(schema.channelCategories.spaceId, ids)).all(),
|
||||||
|
);
|
||||||
|
const categoriesBySpace = new Map<string, ChannelCategory[]>();
|
||||||
|
for (const cat of allCategories) {
|
||||||
|
let arr = categoriesBySpace.get(cat.spaceId);
|
||||||
|
if (!arr) { arr = []; categoriesBySpace.set(cat.spaceId, arr); }
|
||||||
|
arr.push({
|
||||||
|
id: cat.id,
|
||||||
|
spaceId: cat.spaceId,
|
||||||
|
name: cat.name,
|
||||||
|
position: cat.position ?? 0,
|
||||||
|
createdAt: cat.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Batch: last message ID per channel (1 query instead of N×C)
|
// Batch: last message ID per channel (1 query instead of N×C)
|
||||||
const allChannelIds = allChannels.map(ch => ch.id);
|
const allChannelIds = allChannels.map(ch => ch.id);
|
||||||
const lastMsgMap = new Map<string, string>();
|
const lastMsgMap = new Map<string, string>();
|
||||||
@@ -782,6 +801,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
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,
|
||||||
lastMessageId: lastMsgMap.get(ch.id) ?? null,
|
lastMessageId: lastMsgMap.get(ch.id) ?? null,
|
||||||
myPermissions: permissionsToString(chPerms),
|
myPermissions: permissionsToString(chPerms),
|
||||||
@@ -801,6 +821,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
description: spaceRow.description ?? null,
|
description: spaceRow.description ?? null,
|
||||||
createdAt: spaceRow.createdAt,
|
createdAt: spaceRow.createdAt,
|
||||||
channels: visibleChannels,
|
channels: visibleChannels,
|
||||||
|
categories: categoriesBySpace.get(spaceRow.id) ?? [],
|
||||||
members,
|
members,
|
||||||
roles: roles.map(r => ({
|
roles: roles.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface JoinRequest {
|
|||||||
|
|
||||||
export interface SpaceWithChannelsAndMembers extends Space {
|
export interface SpaceWithChannelsAndMembers extends Space {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
|
categories: ChannelCategory[];
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
myPermissions?: string; // Computed per-user BigInt decimal string (space-level)
|
myPermissions?: string; // Computed per-user BigInt decimal string (space-level)
|
||||||
@@ -125,6 +126,14 @@ export interface SpaceFolder {
|
|||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
export type ChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
|
export interface ChannelCategory {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -132,6 +141,7 @@ export interface Channel {
|
|||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
topic: string | null;
|
topic: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
categoryId: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastMessageId?: string | null;
|
lastMessageId?: string | null;
|
||||||
myPermissions?: string; // Computed per-user BigInt decimal string
|
myPermissions?: string; // Computed per-user BigInt decimal string
|
||||||
@@ -297,6 +307,10 @@ export type ServerEvent =
|
|||||||
| { type: 'voice_disconnected'; userId: string; channelId: string }
|
| { type: 'voice_disconnected'; userId: string; channelId: string }
|
||||||
| { type: 'user_updated'; user: User }
|
| { type: 'user_updated'; user: User }
|
||||||
| { type: 'member_banned'; spaceId: string; reason: string | null }
|
| { type: 'member_banned'; spaceId: string; reason: string | null }
|
||||||
|
| { type: 'category_created'; category: ChannelCategory; spaceId: string }
|
||||||
|
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
|
||||||
|
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
||||||
|
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -334,12 +348,14 @@ export interface CreateChannelRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
categoryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateChannelRequest {
|
export interface UpdateChannelRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
position?: number;
|
position?: number;
|
||||||
|
categoryId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSpaceRequest {
|
export interface UpdateSpaceRequest {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
Space,
|
Space,
|
||||||
SpaceWithChannelsAndMembers,
|
SpaceWithChannelsAndMembers,
|
||||||
Channel,
|
Channel,
|
||||||
|
ChannelCategory,
|
||||||
MessageWithUser,
|
MessageWithUser,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -92,6 +93,13 @@ export class BackspaceApiClient {
|
|||||||
getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>;
|
getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>;
|
||||||
putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>;
|
putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>;
|
||||||
deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>;
|
deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>;
|
||||||
|
updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<{ success: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly categories: {
|
||||||
|
create: (spaceId: string, name: string) => Promise<ChannelCategory>;
|
||||||
|
update: (id: string, data: { name?: string; position?: number }) => Promise<ChannelCategory>;
|
||||||
|
delete: (id: string) => Promise<{ success: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly messages: {
|
readonly messages: {
|
||||||
@@ -313,6 +321,17 @@ export class BackspaceApiClient {
|
|||||||
request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data),
|
request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data),
|
||||||
deleteOverride: (channelId: string, targetType: string, targetId: string) =>
|
deleteOverride: (channelId: string, targetType: string, targetId: string) =>
|
||||||
request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`),
|
request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`),
|
||||||
|
updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) =>
|
||||||
|
request<{ success: boolean }>('PATCH', `/spaces/${spaceId}/channel-layout`, data),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.categories = {
|
||||||
|
create: (spaceId: string, name: string) =>
|
||||||
|
request<ChannelCategory>('POST', `/spaces/${spaceId}/categories`, { name }),
|
||||||
|
update: (id: string, data: { name?: string; position?: number }) =>
|
||||||
|
request<ChannelCategory>('PATCH', `/categories/${id}`, data),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<{ success: boolean }>('DELETE', `/categories/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.messages = {
|
this.messages = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import type { Channel } from '@backspace/shared';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -96,9 +97,213 @@ export function ChannelSidebar() {
|
|||||||
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
|
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
|
||||||
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
|
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
|
||||||
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
|
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
|
||||||
|
const categories = useSpaceStore((s) => s.categories);
|
||||||
|
|
||||||
const textChannels = channels.filter(c => c.type === 'text');
|
// Drag state for channel/category reordering
|
||||||
const voiceChannels = channels.filter(c => c.type === 'voice');
|
const [channelDragState, setChannelDragState] = useState<{ dragType: 'channel' | 'category'; dragId: string } | null>(null);
|
||||||
|
const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after'; type: 'channel' | 'category' } | null>(null);
|
||||||
|
|
||||||
|
// Collapse state — persisted in localStorage
|
||||||
|
const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`;
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(collapseKey);
|
||||||
|
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||||
|
} catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const toggleCollapse = useCallback((categoryId: string) => {
|
||||||
|
setCollapsedCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(categoryId)) next.delete(categoryId);
|
||||||
|
else next.add(categoryId);
|
||||||
|
try { localStorage.setItem(collapseKey, JSON.stringify([...next])); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [collapseKey]);
|
||||||
|
|
||||||
|
// Group channels by category
|
||||||
|
const sortedCategories = useMemo(() =>
|
||||||
|
[...categories].sort((a, b) => a.position - b.position), [categories]);
|
||||||
|
const uncategorizedChannels = useMemo(() =>
|
||||||
|
channels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [channels]);
|
||||||
|
const channelsByCategory = useMemo(() => {
|
||||||
|
const map = new Map<string, typeof channels>();
|
||||||
|
for (const ch of channels) {
|
||||||
|
if (!ch.categoryId) continue;
|
||||||
|
let arr = map.get(ch.categoryId);
|
||||||
|
if (!arr) { arr = []; map.set(ch.categoryId, arr); }
|
||||||
|
arr.push(ch);
|
||||||
|
}
|
||||||
|
for (const [key, arr] of map) {
|
||||||
|
map.set(key, arr.sort((a, b) => a.position - b.position));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [channels]);
|
||||||
|
|
||||||
|
// Check if a collapsed category has unread channels
|
||||||
|
const categoryHasUnread = useCallback((categoryId: string) => {
|
||||||
|
const chs = channelsByCategory.get(categoryId) ?? [];
|
||||||
|
return chs.some(ch => unreadChannels.has(ch.id));
|
||||||
|
}, [channelsByCategory, unreadChannels]);
|
||||||
|
|
||||||
|
// DnD handlers
|
||||||
|
const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => {
|
||||||
|
if (!canManageChannels) return;
|
||||||
|
e.dataTransfer.setData('application/x-channel-id', channelId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
setChannelDragState({ dragType: 'channel', dragId: channelId });
|
||||||
|
}, [canManageChannels]);
|
||||||
|
|
||||||
|
const handleCategoryDragStart = useCallback((e: React.DragEvent, categoryId: string) => {
|
||||||
|
if (!canManageChannels) return;
|
||||||
|
e.dataTransfer.setData('application/x-category-id', categoryId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
setChannelDragState({ dragType: 'category', dragId: categoryId });
|
||||||
|
}, [canManageChannels]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setChannelDragState(null);
|
||||||
|
setDropIndicator(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChannelDragOver = useCallback((e: React.DragEvent, targetId: string, type: 'channel' | 'category') => {
|
||||||
|
if (!channelDragState) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
const position = e.clientY < midY ? 'before' : 'after';
|
||||||
|
setDropIndicator({ targetId, position, type });
|
||||||
|
}, [channelDragState]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!channelDragState || !currentSpaceId || !dropIndicator) {
|
||||||
|
setChannelDragState(null);
|
||||||
|
setDropIndicator(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragChannelId = e.dataTransfer.getData('application/x-channel-id');
|
||||||
|
const dragCategoryId = e.dataTransfer.getData('application/x-category-id');
|
||||||
|
|
||||||
|
if (dragChannelId && dropIndicator) {
|
||||||
|
// Channel drag — compute new layout
|
||||||
|
const allChannelsCopy = channels.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
position: ch.position,
|
||||||
|
categoryId: ch.categoryId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (dropIndicator.type === 'channel') {
|
||||||
|
// Dropping on a channel — take its categoryId and insert near it
|
||||||
|
const targetCh = channels.find(c => c.id === dropIndicator.targetId);
|
||||||
|
if (targetCh) {
|
||||||
|
const dragCh = allChannelsCopy.find(c => c.id === dragChannelId);
|
||||||
|
if (dragCh) {
|
||||||
|
dragCh.categoryId = targetCh.categoryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dropIndicator.type === 'category') {
|
||||||
|
// Dropping on a category header — move channel into that category
|
||||||
|
const dragCh = allChannelsCopy.find(c => c.id === dragChannelId);
|
||||||
|
if (dragCh) {
|
||||||
|
dragCh.categoryId = dropIndicator.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate positions: group by category and assign sequential positions
|
||||||
|
const grouped = new Map<string | null, typeof allChannelsCopy>();
|
||||||
|
for (const ch of allChannelsCopy) {
|
||||||
|
const key = ch.categoryId;
|
||||||
|
let arr = grouped.get(key);
|
||||||
|
if (!arr) { arr = []; grouped.set(key, arr); }
|
||||||
|
arr.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within each group, move dragged channel to correct position
|
||||||
|
for (const [, arr] of grouped) {
|
||||||
|
arr.sort((a, b) => a.position - b.position);
|
||||||
|
const dragIdx = arr.findIndex(c => c.id === dragChannelId);
|
||||||
|
if (dragIdx === -1) continue;
|
||||||
|
const dragItem = arr[dragIdx]!;
|
||||||
|
arr.splice(dragIdx, 1);
|
||||||
|
|
||||||
|
// Find target position
|
||||||
|
if (dropIndicator.type === 'channel') {
|
||||||
|
const targetIdx = arr.findIndex(c => c.id === dropIndicator.targetId);
|
||||||
|
if (targetIdx !== -1) {
|
||||||
|
const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1;
|
||||||
|
arr.splice(insertIdx, 0, dragItem);
|
||||||
|
} else {
|
||||||
|
arr.push(dragItem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Dropped on category header — add at start
|
||||||
|
arr.unshift(dragItem);
|
||||||
|
}
|
||||||
|
// Reassign positions
|
||||||
|
arr.forEach((ch, i) => { ch.position = i; });
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelUpdates = allChannelsCopy.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
position: ch.position,
|
||||||
|
categoryId: ch.categoryId,
|
||||||
|
}));
|
||||||
|
const categoryUpdates = sortedCategories.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
position: c.position,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
useSpaceStore.getState().setChannels(
|
||||||
|
channels.map(ch => {
|
||||||
|
const update = channelUpdates.find(u => u.id === ch.id);
|
||||||
|
if (update) return { ...ch, position: update.position, categoryId: update.categoryId };
|
||||||
|
return ch;
|
||||||
|
}).sort((a, b) => a.position - b.position)
|
||||||
|
);
|
||||||
|
|
||||||
|
useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates });
|
||||||
|
} else if (dragCategoryId && dropIndicator?.type === 'category') {
|
||||||
|
// Category drag — reorder categories
|
||||||
|
const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position }));
|
||||||
|
const dragIdx = catsCopy.findIndex(c => c.id === dragCategoryId);
|
||||||
|
if (dragIdx !== -1) {
|
||||||
|
const dragItem = catsCopy[dragIdx]!;
|
||||||
|
catsCopy.splice(dragIdx, 1);
|
||||||
|
const targetIdx = catsCopy.findIndex(c => c.id === dropIndicator.targetId);
|
||||||
|
if (targetIdx !== -1) {
|
||||||
|
const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1;
|
||||||
|
catsCopy.splice(insertIdx, 0, dragItem);
|
||||||
|
} else {
|
||||||
|
catsCopy.push(dragItem);
|
||||||
|
}
|
||||||
|
catsCopy.forEach((c, i) => { c.position = i; });
|
||||||
|
|
||||||
|
const channelUpdates = channels.map(ch => ({
|
||||||
|
id: ch.id,
|
||||||
|
position: ch.position,
|
||||||
|
categoryId: ch.categoryId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
useSpaceStore.getState().setCategories(
|
||||||
|
categories.map(cat => {
|
||||||
|
const update = catsCopy.find(u => u.id === cat.id);
|
||||||
|
if (update) return { ...cat, position: update.position };
|
||||||
|
return cat;
|
||||||
|
}).sort((a, b) => a.position - b.position)
|
||||||
|
);
|
||||||
|
|
||||||
|
useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelDragState(null);
|
||||||
|
setDropIndicator(null);
|
||||||
|
}, [channelDragState, dropIndicator, channels, sortedCategories, categories, currentSpaceId]);
|
||||||
|
|
||||||
const handleChannelClick = (channelId: string) => {
|
const handleChannelClick = (channelId: string) => {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
@@ -362,125 +567,174 @@ export function ChannelSidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channels */}
|
{/* Channels — dynamic category layout */}
|
||||||
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
|
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[2px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||||
{/* Text Channels */}
|
{/* Uncategorized channels */}
|
||||||
<div>
|
{uncategorizedChannels.length > 0 && (
|
||||||
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
<div className="mb-[19px]">
|
||||||
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors">
|
{canManageChannels && sortedCategories.length === 0 && (
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
|
<div className="flex items-center justify-end px-1 mb-1">
|
||||||
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[11px] font-medium uppercase tracking-[0.06em]" style={{ color: '#484854' }}>Text Channels</span>
|
|
||||||
</div>
|
|
||||||
{canManageChannels && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openModal('createChannel');
|
|
||||||
}}
|
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
|
||||||
title="Create Channel"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-[2px]">
|
|
||||||
{textChannels.map((channel) => {
|
|
||||||
const isActive = currentChannelId === channel.id;
|
|
||||||
const isUnread = unreadChannels.has(channel.id) && !isActive;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
onClick={() => openModal('createChannel')}
|
||||||
onClick={() => handleChannelClick(channel.id)}
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
|
title="Create Channel"
|
||||||
isActive
|
|
||||||
? 'bg-surface-elevated text-txt-primary'
|
|
||||||
: isUnread
|
|
||||||
? 'text-white hover:text-white hover:bg-interactive-hover'
|
|
||||||
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isActive && (
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<div
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
|
|
||||||
style={{ height: '55%', opacity: 0.7 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isUnread && (
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-accent-rose" />
|
|
||||||
)}
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
|
|
||||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className={`truncate text-[15px] flex-1 text-left ${isUnread ? 'font-semibold' : 'font-medium'}`}>{channel.name}</span>
|
|
||||||
{canManageChannels && (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openModal('channelSettings', { channelId: channel.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Voice Channels */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
|
|
||||||
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-70">
|
|
||||||
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[11px] font-medium uppercase tracking-[0.06em]" style={{ color: '#484854' }}>Voice Channels</span>
|
|
||||||
</div>
|
|
||||||
{canManageChannels && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openModal('createChannel');
|
|
||||||
}}
|
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
|
||||||
title="Create Channel"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="space-y-[2px]">
|
||||||
<div className="space-y-[2px]">
|
{uncategorizedChannels.map((channel) => (
|
||||||
{voiceChannels.map((channel) => {
|
<ChannelItem
|
||||||
const chPerms = channelPermissions.get(channel.id);
|
|
||||||
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
|
|
||||||
return (
|
|
||||||
<VoiceChannel
|
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
channelId={channel.id}
|
channel={channel}
|
||||||
channelName={channel.name}
|
isActive={currentChannelId === channel.id}
|
||||||
onClick={() => canConnect && handleVoiceJoin(channel.id)}
|
isUnread={unreadChannels.has(channel.id) && currentChannelId !== channel.id}
|
||||||
locked={!canConnect}
|
canManage={canManageChannels}
|
||||||
dragState={voiceDragState}
|
isDragging={channelDragState?.dragType === 'channel' && channelDragState.dragId === channel.id}
|
||||||
onDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
|
dropIndicator={dropIndicator?.targetId === channel.id ? dropIndicator.position : null}
|
||||||
onDragEnd={() => setVoiceDragState(null)}
|
onChannelClick={channel.type === 'voice' ? (() => {
|
||||||
|
const chPerms = channelPermissions.get(channel.id);
|
||||||
|
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
|
||||||
|
if (canConnect) handleVoiceJoin(channel.id);
|
||||||
|
}) : (() => handleChannelClick(channel.id))}
|
||||||
|
onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })}
|
||||||
|
onDragStart={(e) => handleChannelDragStart(e, channel.id)}
|
||||||
|
onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
voiceDragState={voiceDragState}
|
||||||
|
onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
|
||||||
|
onVoiceDragEnd={() => setVoiceDragState(null)}
|
||||||
|
channelPermissions={channelPermissions}
|
||||||
|
handleVoiceJoin={handleVoiceJoin}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Categories with their channels */}
|
||||||
|
{sortedCategories.map((category) => {
|
||||||
|
const catChannels = channelsByCategory.get(category.id) ?? [];
|
||||||
|
const isCollapsed = collapsedCategories.has(category.id);
|
||||||
|
const hasUnread = isCollapsed && categoryHasUnread(category.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="mb-[19px]">
|
||||||
|
{/* Category header */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-1 mb-1 group cursor-pointer ${
|
||||||
|
channelDragState?.dragType === 'category' && channelDragState.dragId === category.id ? 'opacity-50' : ''
|
||||||
|
} ${dropIndicator?.targetId === category.id && dropIndicator.type === 'category' ? 'ring-1 ring-accent-mint/40 rounded' : ''}`}
|
||||||
|
draggable={canManageChannels}
|
||||||
|
onDragStart={(e) => handleCategoryDragStart(e, category.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={(e) => handleChannelDragOver(e, category.id, 'category')}
|
||||||
|
onClick={() => toggleCollapse(category.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-0.5 text-txt-tertiary hover:text-txt-secondary transition-colors min-w-0">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={`opacity-70 transition-transform flex-shrink-0 ${isCollapsed ? '-rotate-90' : ''}`}>
|
||||||
|
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-[0.06em] truncate" style={{ color: '#484854' }}>{category.name}</span>
|
||||||
|
{hasUnread && (
|
||||||
|
<div className="ml-1 w-1.5 h-1.5 rounded-full bg-accent-rose flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManageChannels && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openModal('createChannel', { categoryId: category.id });
|
||||||
|
}}
|
||||||
|
className="text-txt-tertiary hover:text-txt-primary transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||||||
|
title="Create Channel"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category channels (hidden when collapsed, unless active) */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="space-y-[2px]">
|
||||||
|
{catChannels.map((channel) => (
|
||||||
|
<ChannelItem
|
||||||
|
key={channel.id}
|
||||||
|
channel={channel}
|
||||||
|
isActive={currentChannelId === channel.id}
|
||||||
|
isUnread={unreadChannels.has(channel.id) && currentChannelId !== channel.id}
|
||||||
|
canManage={canManageChannels}
|
||||||
|
isDragging={channelDragState?.dragType === 'channel' && channelDragState.dragId === channel.id}
|
||||||
|
dropIndicator={dropIndicator?.targetId === channel.id ? dropIndicator.position : null}
|
||||||
|
onChannelClick={channel.type === 'voice' ? (() => {
|
||||||
|
const chPerms = channelPermissions.get(channel.id);
|
||||||
|
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
|
||||||
|
if (canConnect) handleVoiceJoin(channel.id);
|
||||||
|
}) : (() => handleChannelClick(channel.id))}
|
||||||
|
onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })}
|
||||||
|
onDragStart={(e) => handleChannelDragStart(e, channel.id)}
|
||||||
|
onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
voiceDragState={voiceDragState}
|
||||||
|
onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })}
|
||||||
|
onVoiceDragEnd={() => setVoiceDragState(null)}
|
||||||
|
channelPermissions={channelPermissions}
|
||||||
|
handleVoiceJoin={handleVoiceJoin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{catChannels.length === 0 && (
|
||||||
|
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* "Create Channel" button if categories exist but no uncategorized channels */}
|
||||||
|
{sortedCategories.length > 0 && canManageChannels && (
|
||||||
|
<div className="px-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('createChannel')}
|
||||||
|
className="w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0">
|
||||||
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[13px] font-medium">Create Channel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create category button */}
|
||||||
|
{canManageChannels && (
|
||||||
|
<div className="px-1">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!currentSpaceId) return;
|
||||||
|
const name = prompt('Category name:');
|
||||||
|
if (name?.trim()) {
|
||||||
|
try {
|
||||||
|
await useSpaceStore.getState().createCategory(currentSpaceId, name.trim());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create category:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0">
|
||||||
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[13px] font-medium">Create Category</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -889,3 +1143,119 @@ function UserAreaPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Channel Item (unified text + voice) ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function ChannelItem({
|
||||||
|
channel,
|
||||||
|
isActive,
|
||||||
|
isUnread,
|
||||||
|
canManage,
|
||||||
|
isDragging,
|
||||||
|
dropIndicator,
|
||||||
|
onChannelClick,
|
||||||
|
onSettingsClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnd,
|
||||||
|
voiceDragState,
|
||||||
|
onVoiceDragStart,
|
||||||
|
onVoiceDragEnd,
|
||||||
|
channelPermissions,
|
||||||
|
handleVoiceJoin,
|
||||||
|
}: {
|
||||||
|
channel: Channel;
|
||||||
|
isActive: boolean;
|
||||||
|
isUnread: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
dropIndicator: 'before' | 'after' | null;
|
||||||
|
onChannelClick: () => void;
|
||||||
|
onSettingsClick: () => void;
|
||||||
|
onDragStart: (e: React.DragEvent) => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
voiceDragState: { userId: string; fromChannelId: string } | null;
|
||||||
|
onVoiceDragStart: (userId: string) => void;
|
||||||
|
onVoiceDragEnd: () => void;
|
||||||
|
channelPermissions: Map<string, string>;
|
||||||
|
handleVoiceJoin: (channelId: string) => void;
|
||||||
|
}) {
|
||||||
|
if (channel.type === 'voice') {
|
||||||
|
const chPerms = channelPermissions.get(channel.id);
|
||||||
|
const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${isDragging ? 'opacity-50' : ''}`}
|
||||||
|
draggable={canManage}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
|
||||||
|
<VoiceChannel
|
||||||
|
channelId={channel.id}
|
||||||
|
channelName={channel.name}
|
||||||
|
onClick={() => canConnect && handleVoiceJoin(channel.id)}
|
||||||
|
locked={!canConnect}
|
||||||
|
dragState={voiceDragState}
|
||||||
|
onDragStart={onVoiceDragStart}
|
||||||
|
onDragEnd={onVoiceDragEnd}
|
||||||
|
/>
|
||||||
|
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${isDragging ? 'opacity-50' : ''}`}
|
||||||
|
draggable={canManage}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
|
||||||
|
<button
|
||||||
|
onClick={onChannelClick}
|
||||||
|
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-surface-elevated text-txt-primary'
|
||||||
|
: isUnread
|
||||||
|
? 'text-white hover:text-white hover:bg-interactive-hover'
|
||||||
|
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className="absolute -left-[2px] top-1/2 -translate-y-1/2 w-[3px] bg-white rounded-r-full"
|
||||||
|
style={{ height: '55%', opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isUnread && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-accent-rose" />
|
||||||
|
)}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 text-[#6e6e7a]">
|
||||||
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
|
||||||
|
</svg>
|
||||||
|
<span className={`truncate text-[15px] flex-1 text-left ${isUnread ? 'font-semibold' : 'font-medium'}`}>{channel.name}</span>
|
||||||
|
{canManage && (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettingsClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
@@ -7,15 +7,25 @@ export function CreateChannelModal() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState<'text' | 'voice'>('text');
|
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState<string | ''>('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
|
const modalData = useUIStore((s) => s.modalData);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const createChannel = useSpaceStore((s) => s.createChannel);
|
const createChannel = useSpaceStore((s) => s.createChannel);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const categories = useSpaceStore((s) => s.categories);
|
||||||
|
|
||||||
const isOpen = activeModal === 'createChannel';
|
const isOpen = activeModal === 'createChannel';
|
||||||
|
|
||||||
|
// Pre-select category when opened from a category's + button
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && modalData.categoryId) {
|
||||||
|
setCategoryId(modalData.categoryId as string);
|
||||||
|
}
|
||||||
|
}, [isOpen, modalData.categoryId]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -32,11 +42,12 @@ export function CreateChannelModal() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined);
|
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined, categoryId || undefined);
|
||||||
closeModal();
|
closeModal();
|
||||||
setName('');
|
setName('');
|
||||||
setTopic('');
|
setTopic('');
|
||||||
setType('text');
|
setType('text');
|
||||||
|
setCategoryId('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create channel');
|
setError(err instanceof Error ? err.message : 'Failed to create channel');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -124,6 +135,24 @@ export function CreateChannelModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">No Category</option>
|
||||||
|
{[...categories].sort((a, b) => a.position - b.position).map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||||||
|
|||||||
@@ -651,6 +651,55 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Category events (all origins) ────────────────────────────────────
|
||||||
|
|
||||||
|
case 'category_created': {
|
||||||
|
const { currentSpaceId: catSpaceId, categories: curCategories, setCategories: setCats, categoryOriginMap: catOriginMap } = useSpaceStore.getState();
|
||||||
|
catOriginMap.set(event.category.id, origin);
|
||||||
|
if (event.spaceId === catSpaceId) {
|
||||||
|
if (!curCategories.some(c => c.id === event.category.id)) {
|
||||||
|
setCats([...curCategories, event.category].sort((a, b) => a.position - b.position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'category_updated': {
|
||||||
|
const { currentSpaceId: catSpaceId2, categories: curCategories2, setCategories: setCats2 } = useSpaceStore.getState();
|
||||||
|
if (event.spaceId === catSpaceId2) {
|
||||||
|
setCats2(curCategories2.map(c => c.id === event.category.id ? event.category : c).sort((a, b) => a.position - b.position));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'category_deleted': {
|
||||||
|
const { currentSpaceId: catSpaceId3, categories: curCategories3, setCategories: setCats3, channels: curChsForCat, setChannels: setChsForCat, categoryOriginMap: catOriginMap3 } = useSpaceStore.getState();
|
||||||
|
catOriginMap3.delete(event.categoryId);
|
||||||
|
if (event.spaceId === catSpaceId3) {
|
||||||
|
setCats3(curCategories3.filter(c => c.id !== event.categoryId));
|
||||||
|
// Null out categoryId on affected channels (server already did this, but sync local state)
|
||||||
|
setChsForCat(curChsForCat.map(ch => ch.categoryId === event.categoryId ? { ...ch, categoryId: null } : ch));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'channel_layout_updated': {
|
||||||
|
const { currentSpaceId: layoutSpaceId, setChannels: setLayoutChannels, setCategories: setLayoutCategories, channelPermissions: layoutChPerms, channelToSpaceMap: layoutCtsMap, channelOriginMap: layoutCoMap } = useSpaceStore.getState();
|
||||||
|
if (event.spaceId === layoutSpaceId) {
|
||||||
|
setLayoutChannels(event.channels.sort((a, b) => a.position - b.position));
|
||||||
|
setLayoutCategories(event.categories.sort((a, b) => a.position - b.position));
|
||||||
|
// Update permission maps from the new layout
|
||||||
|
for (const ch of event.channels) {
|
||||||
|
layoutCtsMap.set(ch.id, event.spaceId);
|
||||||
|
layoutCoMap.set(ch.id, origin);
|
||||||
|
if (ch.myPermissions) {
|
||||||
|
layoutChPerms.set(ch.id, ch.myPermissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
|
import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, 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';
|
||||||
@@ -26,6 +26,7 @@ interface SpaceState {
|
|||||||
spaces: TaggedSpace[];
|
spaces: TaggedSpace[];
|
||||||
currentSpaceId: string | null;
|
currentSpaceId: string | null;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
|
categories: ChannelCategory[];
|
||||||
members: MemberWithUser[];
|
members: MemberWithUser[];
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
folders: SpaceFolder[];
|
folders: SpaceFolder[];
|
||||||
@@ -35,9 +36,11 @@ interface SpaceState {
|
|||||||
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
|
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
|
||||||
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
||||||
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
||||||
|
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
setCurrentSpace: (spaceId: string | null) => void;
|
setCurrentSpace: (spaceId: string | null) => void;
|
||||||
setChannels: (channels: Channel[]) => void;
|
setChannels: (channels: Channel[]) => void;
|
||||||
|
setCategories: (categories: ChannelCategory[]) => void;
|
||||||
setMembers: (members: MemberWithUser[]) => void;
|
setMembers: (members: MemberWithUser[]) => void;
|
||||||
setRoles: (roles: Role[]) => void;
|
setRoles: (roles: Role[]) => void;
|
||||||
setDmChannels: (channels: DmChannel[]) => void;
|
setDmChannels: (channels: DmChannel[]) => void;
|
||||||
@@ -57,8 +60,12 @@ interface SpaceState {
|
|||||||
leaveSpace: (spaceId: string) => Promise<void>;
|
leaveSpace: (spaceId: string) => Promise<void>;
|
||||||
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
|
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
|
||||||
generateInvite: (spaceId: string) => Promise<string>;
|
generateInvite: (spaceId: string) => Promise<string>;
|
||||||
createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => Promise<Channel>;
|
createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => Promise<Channel>;
|
||||||
deleteChannel: (channelId: string) => Promise<void>;
|
deleteChannel: (channelId: string) => Promise<void>;
|
||||||
|
createCategory: (spaceId: string, name: string) => Promise<ChannelCategory>;
|
||||||
|
updateCategory: (categoryId: string, data: { name?: string; position?: number }) => Promise<void>;
|
||||||
|
deleteCategory: (categoryId: string) => Promise<void>;
|
||||||
|
updateChannelLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<void>;
|
||||||
addSpace: (space: Space) => void;
|
addSpace: (space: Space) => void;
|
||||||
removeSpace: (spaceId: string) => void;
|
removeSpace: (spaceId: string) => void;
|
||||||
updateMemberPresence: (userId: string, status: string) => void;
|
updateMemberPresence: (userId: string, status: string) => void;
|
||||||
@@ -76,6 +83,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
spaces: [],
|
spaces: [],
|
||||||
currentSpaceId: null,
|
currentSpaceId: null,
|
||||||
channels: [],
|
channels: [],
|
||||||
|
categories: [],
|
||||||
members: [],
|
members: [],
|
||||||
roles: [],
|
roles: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -85,10 +93,12 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
spacePermissions: new Map(),
|
spacePermissions: new Map(),
|
||||||
channelPermissions: new Map(),
|
channelPermissions: new Map(),
|
||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
|
categoryOriginMap: new Map(),
|
||||||
|
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
||||||
setChannels: (channels) => set({ channels }),
|
setChannels: (channels) => set({ channels }),
|
||||||
|
setCategories: (categories) => set({ categories }),
|
||||||
setMembers: (members) => set({ members }),
|
setMembers: (members) => set({ members }),
|
||||||
setRoles: (roles) => set({ roles }),
|
setRoles: (roles) => set({ roles }),
|
||||||
setDmChannels: (dmChannels) => set({ dmChannels }),
|
setDmChannels: (dmChannels) => set({ dmChannels }),
|
||||||
@@ -185,6 +195,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
currentSpaceId: spaceId,
|
currentSpaceId: spaceId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
|
categories: (detail.categories || []).sort((a, b) => a.position - b.position),
|
||||||
members: detail.members,
|
members: detail.members,
|
||||||
roles: detail.roles.sort((a, b) => b.position - a.position),
|
roles: detail.roles.sort((a, b) => b.position - a.position),
|
||||||
spacePermissions,
|
spacePermissions,
|
||||||
@@ -294,8 +305,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
return result.inviteCode;
|
return result.inviteCode;
|
||||||
},
|
},
|
||||||
|
|
||||||
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => {
|
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => {
|
||||||
const channel = await api.channels.create(spaceId, { name, type, topic });
|
const space = get().spaces.find(s => s.id === spaceId);
|
||||||
|
const origin = space?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
const channel = await client.channels.create(spaceId, { name, type, topic, categoryId });
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.channels.some(c => c.id === channel.id)) return state;
|
if (state.channels.some(c => c.id === channel.id)) return state;
|
||||||
return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) };
|
return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) };
|
||||||
@@ -310,6 +324,47 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createCategory: async (spaceId: string, name: string) => {
|
||||||
|
const space = get().spaces.find(s => s.id === spaceId);
|
||||||
|
const origin = space?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
const category = await client.categories.create(spaceId, name);
|
||||||
|
// Will be added via WS event, but add optimistically
|
||||||
|
set((state) => {
|
||||||
|
if (state.categories.some(c => c.id === category.id)) return state;
|
||||||
|
return { categories: [...state.categories, category].sort((a, b) => a.position - b.position) };
|
||||||
|
});
|
||||||
|
return category;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCategory: async (categoryId: string, data: { name?: string; position?: number }) => {
|
||||||
|
const cat = get().categories.find(c => c.id === categoryId);
|
||||||
|
if (!cat) return;
|
||||||
|
const space = get().spaces.find(s => s.id === cat.spaceId);
|
||||||
|
const origin = space?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
await client.categories.update(categoryId, data);
|
||||||
|
// WS event will update the store
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCategory: async (categoryId: string) => {
|
||||||
|
const cat = get().categories.find(c => c.id === categoryId);
|
||||||
|
if (!cat) return;
|
||||||
|
const space = get().spaces.find(s => s.id === cat.spaceId);
|
||||||
|
const origin = space?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
await client.categories.delete(categoryId);
|
||||||
|
// WS events will update the store
|
||||||
|
},
|
||||||
|
|
||||||
|
updateChannelLayout: async (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => {
|
||||||
|
const space = get().spaces.find(s => s.id === spaceId);
|
||||||
|
const origin = space?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
await client.channels.updateLayout(spaceId, data);
|
||||||
|
// WS event will broadcast the updated layout
|
||||||
|
},
|
||||||
|
|
||||||
addSpace: (space: Space) => {
|
addSpace: (space: Space) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.spaces.find(s => s.id === space.id)) return state;
|
if (state.spaces.find(s => s.id === space.id)) return state;
|
||||||
@@ -386,6 +441,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
const spacePermissions = new Map(get().spacePermissions);
|
const spacePermissions = new Map(get().spacePermissions);
|
||||||
const channelPermissions = new Map(get().channelPermissions);
|
const channelPermissions = new Map(get().channelPermissions);
|
||||||
const channelOriginMap = new Map(get().channelOriginMap);
|
const channelOriginMap = new Map(get().channelOriginMap);
|
||||||
|
const categoryOriginMap = new Map(get().categoryOriginMap);
|
||||||
|
|
||||||
// If home, clear home-origin entries first to avoid stale data
|
// If home, clear home-origin entries first to avoid stale data
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
@@ -435,6 +491,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelPermissions.set(ch.id, ch.myPermissions);
|
channelPermissions.set(ch.id, ch.myPermissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (srv.categories) {
|
||||||
|
for (const cat of srv.categories) {
|
||||||
|
categoryOriginMap.set(cat.id, origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM channels: process from any origin, normalize remote assets
|
// DM channels: process from any origin, normalize remote assets
|
||||||
@@ -467,6 +528,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
spacePermissions,
|
spacePermissions,
|
||||||
channelPermissions,
|
channelPermissions,
|
||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
|
categoryOriginMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set folders from home origin
|
// Only set folders from home origin
|
||||||
|
|||||||
Reference in New Issue
Block a user