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, + ); +}