From f27140f1c88f022e471d95badc8acfe73356cc79 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Mon, 23 Mar 2026 19:34:24 +0100
Subject: [PATCH] feat: add space folder support to MobileSpacesScreen
Folder-aware layout resolution, folder icons in space strip,
bottom sheet folder flyout with rename/color/ungroup management.
Full folder CRUD via context menus: create, move to, remove from.
---
.../components/layout/MobileFolderSheet.tsx | 150 ++++++++++++++++++
.../components/layout/MobileSpacesScreen.tsx | 136 +++++++++++++++-
2 files changed, 282 insertions(+), 4 deletions(-)
create mode 100644 packages/web/src/components/layout/MobileFolderSheet.tsx
diff --git a/packages/web/src/components/layout/MobileFolderSheet.tsx b/packages/web/src/components/layout/MobileFolderSheet.tsx
new file mode 100644
index 00000000..57d7f11b
--- /dev/null
+++ b/packages/web/src/components/layout/MobileFolderSheet.tsx
@@ -0,0 +1,150 @@
+import React, { useState } from 'react';
+import type { SpaceFolder } from '@backspace/shared';
+import { useSpaceStore } from '../../stores/spaceStore';
+import { useContextMenuStore } from '../../stores/contextMenuStore';
+import { getSpaceGradient } from '../../utils/gradients';
+
+const FOLDER_COLORS = [
+ { name: 'Mint', value: 'rgb(var(--accent-mint))' },
+ { name: 'Peach', value: 'rgb(var(--accent-peach))' },
+ { name: 'Lavender', value: 'rgb(var(--accent-lavender))' },
+ { name: 'Sky', value: 'rgb(var(--accent-sky))' },
+ { name: 'Amber', value: 'rgb(var(--accent-amber))' },
+ { name: 'Rose', value: 'rgb(var(--accent-rose))' },
+ { name: 'Coral', value: 'rgb(var(--accent-coral))' },
+];
+
+interface MobileFolderSheetProps {
+ folder: SpaceFolder;
+ onClose: () => void;
+ onSelectSpace: (spaceId: string) => void;
+ onUpdateFolder: (folderId: string, updates: { name?: string | null; color?: string | null }) => void;
+ onUngroup: (folderId: string) => void;
+}
+
+export function MobileFolderSheet({ folder, onClose, onSelectSpace, onUpdateFolder, onUngroup }: MobileFolderSheetProps) {
+ const spaces = useSpaceStore((s) => s.spaces);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [renameValue, setRenameValue] = useState(folder.name || '');
+ const openContextMenu = useContextMenuStore((s) => s.open);
+
+ const folderSpaces = folder.spaceIds
+ .map(sid => spaces.find(s => s.id === sid))
+ .filter(Boolean) as typeof spaces;
+
+ const handleRename = () => {
+ const trimmed = renameValue.trim();
+ onUpdateFolder(folder.id, { name: trimmed || null });
+ setIsRenaming(false);
+ };
+
+ const handleFolderContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openContextMenu({ x: e.clientX, y: e.clientY }, [
+ {
+ key: 'rename',
+ type: 'action',
+ label: 'Rename Folder',
+ icon: ,
+ onClick: () => setIsRenaming(true),
+ },
+ {
+ key: 'color',
+ type: 'custom',
+ render: () => (
+
+
Folder Color
+
+
+
+ ),
+ },
+ { key: 'sep', type: 'separator' },
+ {
+ key: 'ungroup',
+ type: 'action',
+ label: 'Ungroup',
+ danger: true,
+ icon: ,
+ onClick: () => { onUngroup(folder.id); onClose(); },
+ },
+ ]);
+ };
+
+ return (
+ <>
+
+
+
+
+ {/* Folder header */}
+
+
+ {isRenaming ? (
+
setRenameValue(e.target.value)}
+ onBlur={handleRename}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setIsRenaming(false); }}
+ />
+ ) : (
+
+ {folder.name || 'Unnamed Folder'}
+
+ )}
+
{folderSpaces.length} spaces
+
+
+ {/* Folder spaces */}
+
+ {folderSpaces.map(space => {
+ const iconUrl = space.icon
+ ? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`)
+ : null;
+ const grad = getSpaceGradient(space.id, space.name, space.avatarColor);
+ return (
+
{ onSelectSpace(space.id); onClose(); }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-interactive-hover text-left transition-colors"
+ >
+
+ {iconUrl ? (
+

+ ) : (
+
{space.name.charAt(0).toUpperCase()}
+ )}
+
+ {space.name}
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx
index 11c35121..48b17f7d 100644
--- a/packages/web/src/components/layout/MobileSpacesScreen.tsx
+++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx
@@ -9,12 +9,17 @@ import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextM
import { useNavigate } from 'react-router-dom';
import { getSpaceGradient } from '../../utils/gradients';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
-import type { Channel } from '@backspace/shared';
+import type { Channel, SpaceFolder } from '@backspace/shared';
import { Mascot } from '../ui/Mascot';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
+import { MobileFolderSheet } from './MobileFolderSheet';
import { useInstanceStore } from '../../stores/instanceStore';
+type ResolvedItem =
+ | { type: 'space'; space: TaggedSpace }
+ | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
+
export function MobileSpacesScreen() {
const spaces = useSpaceStore((s) => s.spaces);
const channels = useSpaceStore((s) => s.channels);
@@ -56,6 +61,7 @@ export function MobileSpacesScreen() {
const [deleteChannelId, setDeleteChannelId] = useState(null);
const [deleteCategoryId, setDeleteCategoryId] = useState(null);
const [transferModalSpaceId, setTransferModalSpaceId] = useState(null);
+ const [openFolderId, setOpenFolderId] = useState(null);
// Sync selected space with store's current space
useEffect(() => {
@@ -115,6 +121,47 @@ export function MobileSpacesScreen() {
return false;
};
+ // ─── Folder-aware layout resolution ─────────────────────────────────
+
+ const spaceMap = useMemo(() => new Map(spaces.map(s => [s.id, s])), [spaces]);
+ const folderMap = useMemo(() => new Map(folders.map(f => [f.id, f])), [folders]);
+
+ const resolvedLayout = useMemo((): ResolvedItem[] => {
+ const result: ResolvedItem[] = [];
+ const accountedSpaceIds = new Set();
+
+ if (spaceLayout && spaceLayout.length > 0) {
+ for (const item of spaceLayout) {
+ if (item.t === 's') {
+ const space = spaceMap.get(item.id);
+ if (space) {
+ result.push({ type: 'space', space });
+ accountedSpaceIds.add(item.id);
+ }
+ } else if (item.t === 'f') {
+ const folder = folderMap.get(item.id);
+ if (folder) {
+ const folderSpaces = folder.spaceIds
+ .map(sid => spaceMap.get(sid))
+ .filter((s): s is TaggedSpace => !!s);
+ if (folderSpaces.length > 0) {
+ result.push({ type: 'folder', folder, spaces: folderSpaces });
+ for (const s of folderSpaces) accountedSpaceIds.add(s.id);
+ }
+ }
+ }
+ }
+ }
+
+ for (const space of spaces) {
+ if (!accountedSpaceIds.has(space.id)) {
+ result.push({ type: 'space', space });
+ }
+ }
+
+ return result;
+ }, [spaceLayout, spaces, spaceMap, folderMap]);
+
const handleSpaceSelect = (spaceId: string) => {
setSelectedSpaceId(spaceId);
setCollapsedCategories(new Set());
@@ -219,6 +266,43 @@ export function MobileSpacesScreen() {
addToast('Removed from folder', 'success', 3000);
};
+ const handleUngroup = async (folderId: string) => {
+ const folder = folders.find(f => f.id === folderId);
+ if (!folder) return;
+
+ const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id }));
+ const folderIdx = currentLayout.findIndex(item => item.t === 'f' && item.id === folderId);
+
+ // Replace folder with its spaces
+ const newLayout = [...currentLayout];
+ const spaceItems = folder.spaceIds.map(sid => ({ t: 's' as const, id: sid }));
+ newLayout.splice(folderIdx, 1, ...spaceItems);
+
+ const folderData: Record = {};
+ for (const f of folders) {
+ if (f.id !== folderId) {
+ folderData[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds };
+ }
+ }
+
+ await updateSpaceLayout(newLayout, folderData);
+ setOpenFolderId(null);
+ addToast('Folder ungrouped', 'success', 3000);
+ };
+
+ const handleUpdateFolder = async (folderId: string, updates: { name?: string | null; color?: string | null }) => {
+ const folderData: Record = {};
+ for (const f of folders) {
+ folderData[f.id] = {
+ name: f.id === folderId && updates.name !== undefined ? updates.name : f.name,
+ color: f.id === folderId && updates.color !== undefined ? updates.color : f.color,
+ spaceIds: f.spaceIds,
+ };
+ }
+ const currentLayout = spaceLayout || spaces.map(s => ({ t: 's' as const, id: s.id }));
+ await updateSpaceLayout(currentLayout, folderData);
+ };
+
// ─── Context menu handlers ────────────────────────────────────────────
const handleSpaceContextMenu = (e: React.MouseEvent, spaceId: string) => {
@@ -430,8 +514,36 @@ export function MobileSpacesScreen() {
- {/* Space icons */}
- {spaces.map(space => {
+ {/* Space icons (folder-aware) */}
+ {resolvedLayout.map((item) => {
+ if (item.type === 'folder') {
+ const folder = item.folder;
+ const hasUnread = item.spaces.some(s => spaceHasUnread(s.id));
+ const isSelected = item.spaces.some(s => s.id === selectedSpaceId);
+ return (
+
+
+
setOpenFolderId(folder.id)}
+ onContextMenu={(e) => e.preventDefault()}
+ className={`w-10 h-10 rounded-2xl flex items-center justify-center transition-all ${
+ isSelected ? 'rounded-xl ring-2 ring-accent-primary/50' : 'hover:rounded-xl'
+ } bg-surface-elevated`}
+ >
+
+
+
+ );
+ }
+
+ // Standalone space — full space icon rendering with federation badge and context menu
+ const space = item.space;
const isSelected = space.id === selectedSpaceId;
const hasUnread = spaceHasUnread(space.id);
const iconUrl = space.icon
@@ -472,7 +584,7 @@ export function MobileSpacesScreen() {
if (!origin) return null;
const instances = useInstanceStore.getState().instances;
const inst = instances.find(i => i.origin === origin);
- const isDisconnected = inst ? !inst.connected : false;
+ const isDisconnected = inst ? inst.status !== 'connected' : false;
return (
{isDisconnected ? (
@@ -687,6 +799,22 @@ export function MobileSpacesScreen() {
onClose={() => setTransferModalSpaceId(null)}
/>
)}
+
+ {openFolderId && (() => {
+ const folder = folders.find(f => f.id === openFolderId);
+ if (!folder) return null;
+ return (
+ setOpenFolderId(null)}
+ onSelectSpace={(spaceId) => {
+ handleSpaceSelect(spaceId);
+ }}
+ onUpdateFolder={handleUpdateFolder}
+ onUngroup={handleUngroup}
+ />
+ );
+ })()}
);
}