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.
This commit is contained in:
Jannis Braun
2026-03-12 04:18:01 +01:00
parent 585988802d
commit b776e0e5b0
6 changed files with 205 additions and 177 deletions
+2 -1
View File
@@ -174,7 +174,8 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS space_folder_members ( CREATE TABLE IF NOT EXISTS space_folder_members (
folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE, folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, space_id TEXT NOT NULL,
position INTEGER DEFAULT 0,
PRIMARY KEY (folder_id, space_id) PRIMARY KEY (folder_id, space_id)
); );
+22
View File
@@ -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.'); console.log('Migrations complete.');
} }
+1 -1
View File
@@ -191,7 +191,7 @@ export const spaceFolders = sqliteTable('space_folders', {
export const spaceFolderMembers = sqliteTable('space_folder_members', { export const spaceFolderMembers = sqliteTable('space_folder_members', {
folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }), folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), spaceId: text('space_id').notNull(), // No FK — federated space IDs don't exist locally
position: integer('position').default(0), position: integer('position').default(0),
}, (table) => ({ }, (table) => ({
pk: primaryKey({ columns: [table.folderId, table.spaceId] }), pk: primaryKey({ columns: [table.folderId, table.spaceId] }),
+2 -1
View File
@@ -420,10 +420,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 }); 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) => { db.transaction((tx) => {
tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run(); 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.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(); tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run();
}); });
@@ -9,6 +9,7 @@ import { useInstanceStore } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { Tooltip } from '../ui/Tooltip'; import { Tooltip } from '../ui/Tooltip';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
@@ -164,12 +165,12 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDrop={onDrop} onDrop={onDrop}
> >
{/* Drop indicator lines */} {/* Drop indicator lines — offset into the mb-1.5 gap so adjacent items share one line */}
{dropIndicator === 'before' && ( {dropIndicator === 'before' && (
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" /> <div className="absolute -top-[3px] left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)} )}
{dropIndicator === 'after' && ( {dropIndicator === 'after' && (
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" /> <div className="absolute -bottom-[3px] left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)} )}
{/* Pill Indicator */} {/* 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<HTMLDivElement>(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<string | null>(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(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
ref={modalRef}
className="w-[380px] max-h-[480px] glass-modal rounded-xl flex flex-col animate-in fade-in zoom-in-95 duration-150"
>
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-white/[0.06]">
<h3 className="text-base font-semibold text-txt-primary">Transfer Ownership</h3>
<p className="text-xs text-txt-tertiary mt-0.5">
Choose a member to become the new owner of <span className="font-medium text-txt-secondary">{space.name}</span>
</p>
</div>
{selectedUserId && selectedMember ? (
/* Confirm step */
<div className="p-4 flex flex-col gap-4">
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20">
<p className="text-sm text-txt-secondary">
Transfer ownership of <span className="font-semibold text-txt-primary">{space.name}</span> to{' '}
<span className="font-semibold text-txt-primary">{selectedMember.user.displayName || selectedMember.user.username}</span>?
</p>
<p className="text-xs text-txt-tertiary mt-1.5">You will become a regular member.</p>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setSelectedUserId(null)}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
disabled={transferring}
>
Cancel
</button>
<button
onClick={handleTransfer}
disabled={transferring}
className="px-3 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{transferring ? 'Transferring...' : 'Transfer'}
</button>
</div>
</div>
) : (
/* Member list */
<>
<div className="px-3 pt-3">
<input
type="text"
value={search}
onChange={(e) => 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
/>
</div>
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{filteredMembers.length === 0 ? (
<p className="text-xs text-txt-tertiary text-center py-4">No members found</p>
) : (
filteredMembers.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
<button
key={member.userId}
onClick={() => setSelectedUserId(member.userId)}
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-surface-input flex-shrink-0 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-txt-secondary">
{(member.user.displayName || member.user.username).charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex flex-col items-start min-w-0">
<span className="text-sm text-txt-primary truncate max-w-full">
{member.user.displayName || member.user.username}
</span>
{member.user.displayName && (
<span className="text-[11px] text-txt-tertiary truncate max-w-full">
{member.user.username}
</span>
)}
</div>
</button>
);
})
)}
</div>
</>
)}
</div>
</div>,
document.body,
);
}
// ─── FolderSlot (single icon slot for a folder) ────────────────────────── // ─── FolderSlot (single icon slot for a folder) ──────────────────────────
function FolderSlot({ function FolderSlot({
@@ -840,12 +673,12 @@ function FolderSlot({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDrop={onDrop} onDrop={onDrop}
> >
{/* Drop indicators */} {/* Drop indicators — offset into the mb-1.5 gap so adjacent items share one line */}
{dropIndicator === 'before' && ( {dropIndicator === 'before' && (
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" /> <div className="absolute -top-[3px] left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)} )}
{dropIndicator === 'after' && ( {dropIndicator === 'after' && (
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" /> <div className="absolute -bottom-[3px] left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)} )}
{dropIndicator === 'merge' && ( {dropIndicator === 'merge' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
@@ -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<HTMLDivElement>(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<string | null>(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(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
ref={modalRef}
className="w-[380px] max-h-[480px] glass-modal rounded-xl flex flex-col animate-in fade-in zoom-in-95 duration-150"
>
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-white/[0.06]">
<h3 className="text-base font-semibold text-txt-primary">Transfer Ownership</h3>
<p className="text-xs text-txt-tertiary mt-0.5">
Choose a member to become the new owner of <span className="font-medium text-txt-secondary">{space.name}</span>
</p>
</div>
{selectedUserId && selectedMember ? (
/* Confirm step */
<div className="p-4 flex flex-col gap-4">
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20">
<p className="text-sm text-txt-secondary">
Transfer ownership of <span className="font-semibold text-txt-primary">{space.name}</span> to{' '}
<span className="font-semibold text-txt-primary">{selectedMember.user.displayName || selectedMember.user.username}</span>?
</p>
<p className="text-xs text-txt-tertiary mt-1.5">You will become a regular member.</p>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setSelectedUserId(null)}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
disabled={transferring}
>
Cancel
</button>
<button
onClick={handleTransfer}
disabled={transferring}
className="px-3 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{transferring ? 'Transferring...' : 'Transfer'}
</button>
</div>
</div>
) : (
/* Member list */
<>
<div className="px-3 pt-3">
<input
type="text"
value={search}
onChange={(e) => 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
/>
</div>
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{filteredMembers.length === 0 ? (
<p className="text-xs text-txt-tertiary text-center py-4">No members found</p>
) : (
filteredMembers.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
<button
key={member.userId}
onClick={() => setSelectedUserId(member.userId)}
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-surface-input flex-shrink-0 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-txt-secondary">
{(member.user.displayName || member.user.username).charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex flex-col items-start min-w-0">
<span className="text-sm text-txt-primary truncate max-w-full">
{member.user.displayName || member.user.username}
</span>
{member.user.displayName && (
<span className="text-[11px] text-txt-tertiary truncate max-w-full">
{member.user.username}
</span>
)}
</div>
</button>
);
})
)}
</div>
</>
)}
</div>
</div>,
document.body,
);
}