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 { useNavigate } from 'react-router-dom';
|
||||||
import { getSpaceGradient } from '../../utils/gradients';
|
import { getSpaceGradient } from '../../utils/gradients';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
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 { Mascot } from '../ui/Mascot';
|
||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
|
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
|
||||||
|
import { MobileFolderSheet } from './MobileFolderSheet';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
|
|
||||||
|
type ResolvedItem =
|
||||||
|
| { type: 'space'; space: TaggedSpace }
|
||||||
|
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
|
||||||
|
|
||||||
export function MobileSpacesScreen() {
|
export function MobileSpacesScreen() {
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const channels = useSpaceStore((s) => s.channels);
|
const channels = useSpaceStore((s) => s.channels);
|
||||||
@@ -56,6 +61,7 @@ export function MobileSpacesScreen() {
|
|||||||
const [deleteChannelId, setDeleteChannelId] = useState<string | null>(null);
|
const [deleteChannelId, setDeleteChannelId] = useState<string | null>(null);
|
||||||
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
||||||
const [transferModalSpaceId, setTransferModalSpaceId] = 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
|
// Sync selected space with store's current space
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,6 +121,47 @@ export function MobileSpacesScreen() {
|
|||||||
return false;
|
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) => {
|
const handleSpaceSelect = (spaceId: string) => {
|
||||||
setSelectedSpaceId(spaceId);
|
setSelectedSpaceId(spaceId);
|
||||||
setCollapsedCategories(new Set());
|
setCollapsedCategories(new Set());
|
||||||
@@ -219,6 +266,43 @@ export function MobileSpacesScreen() {
|
|||||||
addToast('Removed from folder', 'success', 3000);
|
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 ────────────────────────────────────────────
|
// ─── Context menu handlers ────────────────────────────────────────────
|
||||||
|
|
||||||
const handleSpaceContextMenu = (e: React.MouseEvent, spaceId: string) => {
|
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" />
|
<div className="w-8 h-px bg-border-soft my-0.5" />
|
||||||
|
|
||||||
{/* Space icons */}
|
{/* Space icons (folder-aware) */}
|
||||||
{spaces.map(space => {
|
{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 isSelected = space.id === selectedSpaceId;
|
||||||
const hasUnread = spaceHasUnread(space.id);
|
const hasUnread = spaceHasUnread(space.id);
|
||||||
const iconUrl = space.icon
|
const iconUrl = space.icon
|
||||||
@@ -472,7 +584,7 @@ export function MobileSpacesScreen() {
|
|||||||
if (!origin) return null;
|
if (!origin) return null;
|
||||||
const instances = useInstanceStore.getState().instances;
|
const instances = useInstanceStore.getState().instances;
|
||||||
const inst = instances.find(i => i.origin === origin);
|
const inst = instances.find(i => i.origin === origin);
|
||||||
const isDisconnected = inst ? !inst.connected : false;
|
const isDisconnected = inst ? inst.status !== 'connected' : false;
|
||||||
return (
|
return (
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
|
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
|
||||||
{isDisconnected ? (
|
{isDisconnected ? (
|
||||||
@@ -687,6 +799,22 @@ export function MobileSpacesScreen() {
|
|||||||
onClose={() => setTransferModalSpaceId(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user