From 585988802d8d3b04c3ee90bf83c0166ec7aa38fa Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:56:39 +0100 Subject: [PATCH] feat: folder flyout popover replaces inline expanded folder view Folders now always occupy a single icon slot with a glass-pill styled icon. Clicking opens a flyout popover to the right showing contained spaces with navigation, unread dots, and federation badges. Removes the collapsed/expanded toggle and inline rendering in favor of a condensed, Discord-style folder UX. --- .../src/components/layout/SpaceSidebar.tsx | 498 +++++++++++------- 1 file changed, 317 insertions(+), 181 deletions(-) diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 31a0ccee..101737c1 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -12,28 +12,13 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'; import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; // ─── Resolved layout types ───────────────────────────────────────────────── type ResolvedItem = | { type: 'space'; space: TaggedSpace } - | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[]; collapsed: boolean }; - -// ─── localStorage keys ──────────────────────────────────────────────────── - -const COLLAPSED_KEY = 'backspace:collapsed-space-folders'; - -function getCollapsedFolders(): Set { - try { - const raw = localStorage.getItem(COLLAPSED_KEY); - if (raw) return new Set(JSON.parse(raw)); - } catch { /* ignore */ } - return new Set(); -} - -function setCollapsedFolders(ids: Set): void { - localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...ids])); -} + | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] }; // ─── Folder color presets ───────────────────────────────────────────────── @@ -231,21 +216,25 @@ function MiniSpaceIcon({ space }: { space: TaggedSpace }) { ); } -// ─── Collapsed folder icon (2×2 grid) ───────────────────────────────────── +// ─── Folder icon (2×2 grid with glass-pill) ─────────────────────────────── -function CollapsedFolderIcon({ spaces, color }: { spaces: TaggedSpace[]; color: string | null }) { +function FolderIcon({ spaces, color, isActive, isHovered }: { spaces: TaggedSpace[]; color: string | null; isActive: boolean; isHovered: boolean }) { const display = spaces.slice(0, 4); const remaining = spaces.length - 4; + const borderColor = color ? `rgba(${parseInt(color.slice(1, 3), 16)}, ${parseInt(color.slice(3, 5), 16)}, ${parseInt(color.slice(5, 7), 16)}, 0.3)` : undefined; return ( -
- {color &&
} +
{display.map((s) => (
))} - {/* Fill empty cells */} {display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => (
))} @@ -341,6 +330,158 @@ function FolderContextMenu({ folder, x, y, onClose, onRename, onColorChange, onU ); } +// ─── FolderFlyout ───────────────────────────────────────────────────────── + +function FolderFlyout({ + folder, + spaces, + anchorEl, + currentSpaceId, + unreadSpaceIds, + disconnectedOrigins, + renamingFolderId, + onClose, + onSpaceClick, + onSpaceContextMenu, + onRename, + onDragStart, +}: { + folder: SpaceFolder; + spaces: TaggedSpace[]; + anchorEl: HTMLDivElement; + currentSpaceId: string | null; + unreadSpaceIds: Set; + disconnectedOrigins: Set; + renamingFolderId: string | null; + onClose: () => void; + onSpaceClick: (spaceId: string) => void; + onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void; + onRename: (name: string) => void; + onDragStart: (e: React.DragEvent, spaceId: string) => void; +}) { + const anchorRef = useRef(anchorEl); + anchorRef.current = anchorEl; + const floatingRef = useRef(null); + + const { style } = useFloatingPosition(anchorRef, floatingRef, { + placement: 'right', + offset: 12, + }); + + // Close on click-outside and Escape + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + floatingRef.current && !floatingRef.current.contains(e.target as Node) && + !anchorEl.contains(e.target as Node) + ) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose, anchorEl]); + + const isRenaming = renamingFolderId === folder.id; + + return ReactDOM.createPortal( +
+ {/* Folder header */} + {(folder.name || isRenaming) && ( +
+ {isRenaming ? ( + onRename(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onRename(e.currentTarget.value); + if (e.key === 'Escape') onClose(); + }} + /> + ) : ( + + {folder.name} + + )} +
+ )} + + {/* Space rows */} + {spaces.map((space) => { + const isActive = currentSpaceId === space.id; + const hasUnread = unreadSpaceIds.has(space.id) && !isActive; + const origin = space._instanceOrigin; + const isFederated = !!origin; + const isDimmed = isFederated && disconnectedOrigins.has(origin); + const icon = space.icon; + const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null; + + return ( + + ); + })} +
, + document.body, + ); +} + // ─── SpaceContextMenu ───────────────────────────────────────────────────── function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) { @@ -638,6 +779,96 @@ function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose ); } +// ─── FolderSlot (single icon slot for a folder) ────────────────────────── + +function FolderSlot({ + folder, + folderSpaces, + isActive, + hasUnread, + isFlyoutOpen, + isDragging, + dropIndicator, + onToggleFlyout, + onContextMenu, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + anchorRef, +}: { + folder: SpaceFolder; + folderSpaces: TaggedSpace[]; + isActive: boolean; + hasUnread: boolean; + isFlyoutOpen: boolean; + isDragging: boolean; + dropIndicator: 'before' | 'after' | 'merge' | null; + onToggleFlyout: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnd: () => void; + onDrop: (e: React.DragEvent) => void; + anchorRef: (el: HTMLDivElement | null) => void; +}) { + const [isHovered, setIsHovered] = useState(false); + + const getPillHeight = () => { + if (isActive) return 'h-8'; + if (isHovered) return 'h-4'; + if (hasUnread) return 'h-2'; + return 'h-2 scale-0'; + }; + + const iconContent = ( + + ); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onContextMenu={onContextMenu} + draggable + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} + onDrop={onDrop} + > + {/* Drop indicators */} + {dropIndicator === 'before' && ( +
+ )} + {dropIndicator === 'after' && ( +
+ )} + {dropIndicator === 'merge' && ( +
+
+
+ )} + + {/* Pill indicator */} +
+
+
+ + {isFlyoutOpen ? ( + iconContent + ) : ( + + {iconContent} + + )} +
+ ); +} + // ─── SpaceSidebar (main component) ──────────────────────────────────────── export function SpaceSidebar() { @@ -660,22 +891,9 @@ export function SpaceSidebar() { const navigate = useNavigate(); const location = useLocation(); - // Collapsed folders state - const [collapsedFolderIds, setCollapsedFolderIdsState] = useState(getCollapsedFolders); - const setCollapsedFolderIds = useCallback((ids: Set) => { - setCollapsedFolderIdsState(ids); - setCollapsedFolders(ids); - }, []); - - const toggleFolderCollapse = useCallback((folderId: string) => { - setCollapsedFolderIdsState(prev => { - const next = new Set(prev); - if (next.has(folderId)) next.delete(folderId); - else next.add(folderId); - setCollapsedFolders(next); - return next; - }); - }, []); + // Flyout state + const [openFolderId, setOpenFolderId] = useState(null); + const folderAnchorRefs = useRef>(new Map()); // Context menus const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null); @@ -743,7 +961,6 @@ export function SpaceSidebar() { type: 'folder', folder, spaces: folderSpaces, - collapsed: collapsedFolderIds.has(folder.id), }); for (const s of folderSpaces) accountedSpaceIds.add(s.id); } @@ -760,7 +977,7 @@ export function SpaceSidebar() { } return result; - }, [spaceLayout, spaces, spaceMap, folderMap, collapsedFolderIds]); + }, [spaceLayout, spaces, spaceMap, folderMap]); // Compute which spaces have unread channels const unreadSpaceIds = useMemo(() => { @@ -943,7 +1160,6 @@ export function SpaceSidebar() { spaceIds: [targetItem.space.id, dragSpace.id], }, spaces: [targetItem.space, dragSpace], - collapsed: false, }; newLayout[targetIdx] = newFolder; } else if (targetItem.type === 'folder') { @@ -1023,6 +1239,7 @@ export function SpaceSidebar() { }, [resolvedLayout, persistLayout]); const handleUngroup = useCallback((folderId: string) => { + setOpenFolderId(null); const newLayout = resolvedLayout.flatMap(item => { if (item.type === 'folder' && item.folder.id === folderId) { return item.spaces.map(s => ({ type: 'space' as const, space: s })); @@ -1058,6 +1275,13 @@ export function SpaceSidebar() { return currentSpaceId ? folderSpaces.some(s => s.id === currentSpaceId) : false; }, [currentSpaceId]); + // Auto-close flyout when its folder dissolves + useEffect(() => { + if (openFolderId && !resolvedLayout.some(i => i.type === 'folder' && i.folder.id === openFolderId)) { + setOpenFolderId(null); + } + }, [openFolderId, resolvedLayout]); + return ( ); }