From b776e0e5b0fcd3c3e7070c2a3f782dba90734d46 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:18:01 +0100 Subject: [PATCH] fix: federated spaces in folders + DnD double indicator line Remove FK constraint on space_folder_members.space_id so federated space IDs (which don't exist in the local spaces table) can be added to folders without silently failing. Add migration to recreate the table for existing databases and explicit cleanup on space deletion. Offset drop indicator lines by 3px into the mb-1.5 gap so adjacent items share one visual position instead of showing two lines. Extract TransferOwnershipModal (~165 lines) to its own file. --- packages/server/src/db/index.ts | 3 +- packages/server/src/db/migrate.ts | 22 +++ packages/server/src/db/schema.ts | 2 +- packages/server/src/routes/spaces.ts | 3 +- .../src/components/layout/SpaceSidebar.tsx | 181 +----------------- .../modals/TransferOwnershipModal.tsx | 171 +++++++++++++++++ 6 files changed, 205 insertions(+), 177 deletions(-) create mode 100644 packages/web/src/components/modals/TransferOwnershipModal.tsx diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index ba2752d4..8dd5bcfd 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -174,7 +174,8 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS space_folder_members ( folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE, - space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + space_id TEXT NOT NULL, + position INTEGER DEFAULT 0, PRIMARY KEY (folder_id, space_id) ); diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 3d1aeb02..d5507f86 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -251,6 +251,28 @@ export function runMigrations(db: Database.Database): void { } } + // ─── Remove FK constraint from space_folder_members (federated spaces) ──── + { + const tableInfo = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='space_folder_members'" + ).get() as { sql: string } | undefined; + + if (tableInfo && tableInfo.sql.includes('REFERENCES spaces')) { + console.log('Migrating: Removing FK constraint from space_folder_members...'); + db.exec(` + CREATE TABLE space_folder_members_new ( + folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE, + space_id TEXT NOT NULL, + position INTEGER DEFAULT 0, + PRIMARY KEY (folder_id, space_id) + ); + INSERT INTO space_folder_members_new SELECT folder_id, space_id, position FROM space_folder_members; + DROP TABLE space_folder_members; + ALTER TABLE space_folder_members_new RENAME TO space_folder_members; + `); + } + } + console.log('Migrations complete.'); } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 7d25cf67..cf31fe40 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -191,7 +191,7 @@ export const spaceFolders = sqliteTable('space_folders', { export const spaceFolderMembers = sqliteTable('space_folder_members', { folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }), - spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), + spaceId: text('space_id').notNull(), // No FK — federated space IDs don't exist locally position: integer('position').default(0), }, (table) => ({ pk: primaryKey({ columns: [table.folderId, table.spaceId] }), diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index fa3d4d33..df1f0e7e 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -420,10 +420,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 }); } - // Delete all channels (messages cascade), members, then server atomically + // Delete all channels (messages cascade), members, folder refs, then space atomically db.transaction((tx) => { tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run(); tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run(); + tx.delete(schema.spaceFolderMembers).where(eq(schema.spaceFolderMembers.spaceId, id)).run(); tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run(); }); diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 101737c1..b93312b3 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -9,6 +9,7 @@ import { useInstanceStore } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { Tooltip } from '../ui/Tooltip'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; @@ -164,12 +165,12 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe onDragEnd={onDragEnd} onDrop={onDrop} > - {/* Drop indicator lines */} + {/* Drop indicator lines — offset into the mb-1.5 gap so adjacent items share one line */} {dropIndicator === 'before' && ( -
+
)} {dropIndicator === 'after' && ( -
+
)} {/* Pill Indicator */} @@ -611,174 +612,6 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb ); } -// ─── TransferOwnershipModal ─────────────────────────────────────────────── - -function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) { - const modalRef = useRef(null); - const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); - const members = useSpaceStore((s) => s.members); - const currentUserId = useAuthStore((s) => s.user?.id); - const transferOwnership = useSpaceStore((s) => s.transferOwnership); - const addToast = useUIStore((s) => s.addToast); - - const [search, setSearch] = useState(''); - const [selectedUserId, setSelectedUserId] = useState(null); - const [transferring, setTransferring] = useState(false); - - // Filter members: exclude self, filter by search - const filteredMembers = useMemo(() => { - const spaceMembers = members.filter(m => m.userId !== currentUserId); - if (!search.trim()) return spaceMembers; - const q = search.toLowerCase(); - return spaceMembers.filter(m => - m.user.displayName?.toLowerCase().includes(q) || - m.user.username.toLowerCase().includes(q) - ); - }, [members, currentUserId, search]); - - const selectedMember = selectedUserId ? members.find(m => m.userId === selectedUserId) : null; - - // Close on click-outside and escape - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - onClose(); - } - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - if (selectedUserId) { - setSelectedUserId(null); - } else { - onClose(); - } - } - }; - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose, selectedUserId]); - - if (!space) return null; - - const handleTransfer = async () => { - if (!selectedUserId) return; - setTransferring(true); - try { - await transferOwnership(spaceId, selectedUserId); - addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000); - onClose(); - } catch (err) { - addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); - } finally { - setTransferring(false); - } - }; - - return ReactDOM.createPortal( -
-
- {/* Header */} -
-

Transfer Ownership

-

- Choose a member to become the new owner of {space.name} -

-
- - {selectedUserId && selectedMember ? ( - /* Confirm step */ -
-
-

- Transfer ownership of {space.name} to{' '} - {selectedMember.user.displayName || selectedMember.user.username}? -

-

You will become a regular member.

-
-
- - -
-
- ) : ( - /* Member list */ - <> -
- setSearch(e.target.value)} - placeholder="Search members..." - className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50" - autoFocus - /> -
-
- {filteredMembers.length === 0 ? ( -

No members found

- ) : ( - filteredMembers.map((member) => { - const avatarUrl = member.user.avatar - ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) - : null; - return ( - - ); - }) - )} -
- - )} -
-
, - document.body, - ); -} - // ─── FolderSlot (single icon slot for a folder) ────────────────────────── function FolderSlot({ @@ -840,12 +673,12 @@ function FolderSlot({ onDragEnd={onDragEnd} onDrop={onDrop} > - {/* Drop indicators */} + {/* Drop indicators — offset into the mb-1.5 gap so adjacent items share one line */} {dropIndicator === 'before' && ( -
+
)} {dropIndicator === 'after' && ( -
+
)} {dropIndicator === 'merge' && (
diff --git a/packages/web/src/components/modals/TransferOwnershipModal.tsx b/packages/web/src/components/modals/TransferOwnershipModal.tsx new file mode 100644 index 00000000..a55f4ff5 --- /dev/null +++ b/packages/web/src/components/modals/TransferOwnershipModal.tsx @@ -0,0 +1,171 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { useAuthStore } from '../../stores/authStore'; +import { useUIStore } from '../../stores/uiStore'; + +export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) { + const modalRef = useRef(null); + const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); + const members = useSpaceStore((s) => s.members); + const currentUserId = useAuthStore((s) => s.user?.id); + const transferOwnership = useSpaceStore((s) => s.transferOwnership); + const addToast = useUIStore((s) => s.addToast); + + const [search, setSearch] = useState(''); + const [selectedUserId, setSelectedUserId] = useState(null); + const [transferring, setTransferring] = useState(false); + + // Filter members: exclude self, filter by search + const filteredMembers = useMemo(() => { + const spaceMembers = members.filter(m => m.userId !== currentUserId); + if (!search.trim()) return spaceMembers; + const q = search.toLowerCase(); + return spaceMembers.filter(m => + m.user.displayName?.toLowerCase().includes(q) || + m.user.username.toLowerCase().includes(q) + ); + }, [members, currentUserId, search]); + + const selectedMember = selectedUserId ? members.find(m => m.userId === selectedUserId) : null; + + // Close on click-outside and escape + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (selectedUserId) { + setSelectedUserId(null); + } else { + onClose(); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose, selectedUserId]); + + if (!space) return null; + + const handleTransfer = async () => { + if (!selectedUserId) return; + setTransferring(true); + try { + await transferOwnership(spaceId, selectedUserId); + addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000); + } finally { + setTransferring(false); + } + }; + + return ReactDOM.createPortal( +
+
+ {/* Header */} +
+

Transfer Ownership

+

+ Choose a member to become the new owner of {space.name} +

+
+ + {selectedUserId && selectedMember ? ( + /* Confirm step */ +
+
+

+ Transfer ownership of {space.name} to{' '} + {selectedMember.user.displayName || selectedMember.user.username}? +

+

You will become a regular member.

+
+
+ + +
+
+ ) : ( + /* Member list */ + <> +
+ setSearch(e.target.value)} + placeholder="Search members..." + className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50" + autoFocus + /> +
+
+ {filteredMembers.length === 0 ? ( +

No members found

+ ) : ( + filteredMembers.map((member) => { + const avatarUrl = member.user.avatar + ? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`) + : null; + return ( + + ); + }) + )} +
+ + )} +
+
, + document.body, + ); +}