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