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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user