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.
This commit is contained in:
Jannis Braun
2026-03-23 19:34:24 +01:00
parent 0d9ee44eee
commit f27140f1c8
2 changed files with 282 additions and 4 deletions
@@ -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: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z" /></svg>,
onClick: () => setIsRenaming(true),
},
{
key: 'color',
type: 'custom',
render: () => (
<div className="px-5 py-3">
<p className="text-[11px] text-txt-tertiary mb-2">Folder Color</p>
<div className="flex gap-2">
<button
className={`w-6 h-6 rounded-full border-2 ${!folder.color ? 'border-white/40' : 'border-transparent'} bg-white/10`}
onClick={() => { onUpdateFolder(folder.id, { color: null }); useContextMenuStore.getState().close(); }}
/>
{FOLDER_COLORS.map(c => (
<button
key={c.name}
className={`w-6 h-6 rounded-full border-2 ${folder.color === c.value ? 'border-white/40' : 'border-transparent'}`}
style={{ background: c.value }}
onClick={() => { onUpdateFolder(folder.id, { color: c.value }); useContextMenuStore.getState().close(); }}
/>
))}
</div>
</div>
),
},
{ key: 'sep', type: 'separator' },
{
key: 'ungroup',
type: 'action',
label: 'Ungroup',
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>,
onClick: () => { onUngroup(folder.id); onClose(); },
},
]);
};
return (
<>
<div className="fixed inset-0 z-[300] bg-black/50" onClick={onClose} />
<div
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet max-h-[60vh] flex flex-col"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
{/* Folder header */}
<div className="px-4 py-2 flex items-center gap-2 shrink-0" onContextMenu={handleFolderContextMenu}>
<div
className="w-5 h-5 rounded"
style={{ background: folder.color || 'rgb(var(--text-tertiary))' }}
/>
{isRenaming ? (
<input
autoFocus
className="input-embedded text-sm font-semibold text-txt-primary flex-1 bg-transparent"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleRename}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setIsRenaming(false); }}
/>
) : (
<h3 className="text-sm font-semibold text-txt-primary flex-1 truncate">
{folder.name || 'Unnamed Folder'}
</h3>
)}
<span className="text-xs text-txt-tertiary">{folderSpaces.length} spaces</span>
</div>
{/* Folder spaces */}
<div className="flex-1 overflow-y-auto px-2 py-1">
{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 (
<button
key={space.id}
onClick={() => { 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"
>
<div
className="w-8 h-8 rounded-lg overflow-hidden flex items-center justify-center shrink-0"
style={!iconUrl ? { background: grad.gradient } : undefined}
>
{iconUrl ? (
<img src={iconUrl} alt={space.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-white">{space.name.charAt(0).toUpperCase()}</span>
)}
</div>
<span className="text-sm text-txt-primary truncate">{space.name}</span>
</button>
);
})}
</div>
</div>
</>
);
}
@@ -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<string | null>(null);
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
const [transferModalSpaceId, setTransferModalSpaceId] = useState<string | null>(null);
const [openFolderId, setOpenFolderId] = useState<string | null>(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<string>();
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
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() {
<div className="w-8 h-px bg-border-soft my-0.5" />
{/* 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 (
<div key={`folder-${folder.id}`} className="relative">
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 flex items-center">
<div className={`bg-white rounded-r-full transition-all duration-200 w-full ${
isSelected ? 'h-8' : hasUnread ? 'h-2' : 'h-0'
}`} />
</div>
<button
onClick={() => 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`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke={folder.color || 'currentColor'} strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
</button>
</div>
);
}
// 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 (
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
{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 (
<MobileFolderSheet
folder={folder}
onClose={() => setOpenFolderId(null)}
onSelectSpace={(spaceId) => {
handleSpaceSelect(spaceId);
}}
onUpdateFolder={handleUpdateFolder}
onUngroup={handleUngroup}
/>
);
})()}
</div>
);
}