From 873215d8482a8eb82af04ddfc465c6f31da85465 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:46:27 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20category=20management=20UI=20=E2=80=94?= =?UTF-8?q?=20create=20modal=20and=20delete=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CreateCategory modal (replaces browser prompt) and right-click "Delete Category" context menu on category headers with confirmation dialog explaining channels will be uncategorized, not deleted. --- packages/server/src/routes/spaces.ts | 35 ++++- .../web/src/components/layout/AppLayout.tsx | 2 + .../src/components/layout/ChannelSidebar.tsx | 127 +++++++++++------- .../src/components/modals/CreateCategory.tsx | 89 ++++++++++++ packages/web/src/stores/uiStore.ts | 1 + 5 files changed, 208 insertions(+), 46 deletions(-) create mode 100644 packages/web/src/components/modals/CreateCategory.tsx diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 843861a9..405dfe82 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -85,11 +85,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise { const db = getDb(); const spaceId = generateSnowflake(); + const textCategoryId = generateSnowflake(); + const voiceCategoryId = generateSnowflake(); const channelId = generateSnowflake(); + const voiceChannelId = generateSnowflake(); const now = Date.now(); const inviteCode = generateInviteCode(); - // Create server, owner membership, default channel, and @everyone role atomically + // Create server, owner membership, default categories + channels, and @everyone role atomically db.transaction((tx) => { tx.insert(schema.spaces).values({ id: spaceId, @@ -110,12 +113,42 @@ export async function spaceRoutes(app: FastifyInstance): Promise { joinedAt: now, }).run(); + // Default categories + tx.insert(schema.channelCategories).values({ + id: textCategoryId, + spaceId, + name: 'text-channels', + position: 0, + createdAt: now, + }).run(); + + tx.insert(schema.channelCategories).values({ + id: voiceCategoryId, + spaceId, + name: 'voice-channels', + position: 1, + createdAt: now, + }).run(); + + // Default text channel in text-channels category tx.insert(schema.channels).values({ id: channelId, spaceId, name: 'general', type: 'text', position: 0, + categoryId: textCategoryId, + createdAt: now, + }).run(); + + // Default voice channel in voice-channels category + tx.insert(schema.channels).values({ + id: voiceChannelId, + spaceId, + name: 'voice', + type: 'voice', + position: 0, + categoryId: voiceCategoryId, createdAt: now, }).run(); diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 852d93f9..602ef643 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -9,6 +9,7 @@ import { ImagePreview } from '../chat/ImagePreview'; import { CreateSpaceModal } from '../modals/CreateSpace'; import { JoinSpaceModal } from '../modals/JoinSpace'; import { CreateChannelModal } from '../modals/CreateChannel'; +import { CreateCategoryModal } from '../modals/CreateCategory'; import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { SpaceSettingsModal } from '../modals/SpaceSettings'; @@ -271,6 +272,7 @@ export function AppLayout() { + diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index a4937d21..43434392 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -17,6 +17,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { parseFederatedUsername, isSelf } from '../../utils/identity'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { ContextMenu } from '../ui/ContextMenu'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -103,6 +104,10 @@ export function ChannelSidebar() { 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); + // Delete category confirmation state + const [deleteCategoryId, setDeleteCategoryId] = useState(null); + const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false); + // Collapse state — persisted in localStorage const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`; const [collapsedCategories, setCollapsedCategories] = useState>(() => { @@ -621,43 +626,64 @@ export function ChannelSidebar() { const isCollapsed = collapsedCategories.has(category.id); const hasUnread = isCollapsed && categoryHasUnread(category.id); + const categoryHeader = ( +
handleCategoryDragStart(e, category.id)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleChannelDragOver(e, category.id, 'category')} + onClick={() => toggleCollapse(category.id)} + > +
+ + + + {category.name} + {hasUnread && ( +
+ )} +
+ {canManageChannels && ( + + )} +
+ ); + return (
{/* Category header */} -
handleCategoryDragStart(e, category.id)} - onDragEnd={handleDragEnd} - onDragOver={(e) => handleChannelDragOver(e, category.id, 'category')} - onClick={() => toggleCollapse(category.id)} - > -
- - - - {category.name} - {hasUnread && ( -
- )} -
- {canManageChannels && ( - - )} -
+ {canManageChannels ? ( + + + + ), + onClick: () => setDeleteCategoryId(category.id), + }, + ]} + > + {categoryHeader} + + ) : categoryHeader} {/* Category channels (hidden when collapsed, unless active) */} {!isCollapsed && ( @@ -715,17 +741,7 @@ export function ChannelSidebar() { {canManageChannels && (
{floatingPanel} + setDeleteCategoryId(null)} + onConfirm={async () => { + if (!deleteCategoryId) return; + setDeleteCategoryLoading(true); + try { + await useSpaceStore.getState().deleteCategory(deleteCategoryId); + setDeleteCategoryId(null); + } catch { + // deleteCategory already shows a toast on error + } finally { + setDeleteCategoryLoading(false); + } + }} + title="Delete Category" + description="Are you sure you want to delete this category? Channels in this category will be moved to uncategorized — no channels will be deleted." + confirmLabel="Delete" + variant="danger" + loading={deleteCategoryLoading} + /> ); } diff --git a/packages/web/src/components/modals/CreateCategory.tsx b/packages/web/src/components/modals/CreateCategory.tsx new file mode 100644 index 00000000..16b35147 --- /dev/null +++ b/packages/web/src/components/modals/CreateCategory.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { Modal } from '../ui/Modal'; +import { useUIStore } from '../../stores/uiStore'; +import { useSpaceStore } from '../../stores/spaceStore'; + +export function CreateCategoryModal() { + const [name, setName] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const activeModal = useUIStore((s) => s.activeModal); + const closeModal = useUIStore((s) => s.closeModal); + const createCategory = useSpaceStore((s) => s.createCategory); + const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + + const isOpen = activeModal === 'createCategory'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!name.trim()) { + setError('Category name is required'); + return; + } + + if (!currentSpaceId) { + setError('No space selected'); + return; + } + + setIsLoading(true); + try { + await createCategory(currentSpaceId, name.trim()); + closeModal(); + setName(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create category'); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(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" + placeholder="new-category" + autoFocus + /> +
+ +
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index be445302..aa3e61b1 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -6,6 +6,7 @@ type ModalType = | 'createSpace' | 'joinSpace' | 'createChannel' + | 'createCategory' | 'invite' | 'userSettings' | 'spaceSettings'