Files
backspace/packages/web/src/components/layout/SpaceSidebar.tsx
T
Jannis Braun 585988802d 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.
2026-03-12 03:56:39 +01:00

1441 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore';
import type { TaggedSpace } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { Tooltip } from '../ui/Tooltip';
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[] };
// ─── Folder color presets ─────────────────────────────────────────────────
const FOLDER_COLORS = [
{ name: 'mint', value: '#86efac' },
{ name: 'peach', value: '#fbbf93' },
{ name: 'lavender', value: '#c4b5fd' },
{ name: 'sky', value: '#7dd3fc' },
{ name: 'amber', value: '#fcd34d' },
{ name: 'rose', value: '#fda4af' },
{ name: 'coral', value: '#fb7185' },
];
// ─── SidebarItem ─────────────────────────────────────────────────────────
interface SidebarItemProps {
id: string;
name: string;
icon?: string | null;
avatarColor?: string | null;
active: boolean;
onClick: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
type?: 'space' | 'dm' | 'action';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
dimmed?: boolean;
federationBadge?: boolean;
federationDisconnected?: boolean;
tooltipText?: string;
draggable?: boolean;
onDragStart?: (e: React.DragEvent) => void;
onDragOver?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onDrop?: (e: React.DragEvent) => void;
isDragging?: boolean;
dropIndicator?: 'before' | 'after' | 'merge' | null;
}
function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText, draggable, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, dropIndicator }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
const getPillHeight = () => {
if (active) return 'h-8';
if (isHovered) return 'h-4';
if (hasUnread && !active) return 'h-2';
return 'h-2 scale-0';
};
const backgroundStyle = useMemo((): React.CSSProperties | undefined => {
if (type === 'action') {
return {
background: isHovered ? 'rgba(134, 239, 172, 0.12)' : 'rgba(255, 255, 255, 0.04)',
};
}
if (type === 'dm') {
return { background: HOME_GRADIENT.gradient };
}
// Space type — if it has a custom icon image, no gradient needed
if (icon) return undefined;
const spaceGrad = getSpaceGradient(id, name, avatarColor);
return { background: spaceGrad.gradient };
}, [type, id, name, icon, avatarColor, isHovered]);
const getButtonClasses = () => {
const base = 'w-10 h-10 flex items-center justify-center duration-200 overflow-hidden [transition:border-radius_0.2s,background_0.2s,color_0.2s]';
if (type === 'dm') {
return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
}
if (type === 'action') {
return `${base} rounded-[20px] hover:rounded-[13px] text-accent-mint`;
}
if (icon) {
return `${base} ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
}
return `${base} text-white ${active ? 'rounded-[13px]' : 'rounded-[20px] hover:rounded-[13px]'}`;
};
const buttonContent = (
<button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={tooltipText ? undefined : name}>
{type === 'dm' ? (
<span className="text-[17px] font-bold">B</span>
) : type === 'action' ? (
actionType === 'add' ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
) : actionType === 'explore' ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
)
) : icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
alt={name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-[15px] font-bold">{firstLetter}</span>
)}
</button>
);
const innerContent = (
<div className={`relative ${dropIndicator === 'merge' ? 'scale-110 ring-2 ring-accent-mint/60 rounded-[16px]' : ''} transition-transform duration-150`}>
{buttonContent}
{federationBadge && (
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-surface-base flex items-center justify-center">
{federationDisconnected ? (
<div className="w-[8px] h-[8px] rounded-full bg-accent-amber" />
) : (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
)}
</div>
)}
</div>
);
return (
<div
className={`relative flex items-center mb-1.5 w-full justify-center ${isDragging ? 'opacity-50' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onContextMenu={onContextMenu}
draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDrop={onDrop}
>
{/* Drop indicator lines */}
{dropIndicator === 'before' && (
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)}
{dropIndicator === 'after' && (
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)}
{/* Pill Indicator */}
{(type === 'space' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
/>
</div>
)}
{tooltipText ? (
<Tooltip content={tooltipText} position="right" delay={300}>
{innerContent}
</Tooltip>
) : (
innerContent
)}
</div>
);
}
// ─── Mini space icon for collapsed folder ──────────────────────────────────
function MiniSpaceIcon({ space }: { space: TaggedSpace }) {
const icon = space.icon;
if (icon) {
return (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
alt=""
className="w-full h-full object-cover rounded-[3px]"
/>
);
}
const grad = getSpaceGradient(space.id, space.name, space.avatarColor);
return (
<div
className="w-full h-full rounded-[3px] flex items-center justify-center text-white"
style={{ background: grad.gradient }}
>
<span className="text-[7px] font-bold leading-none">{space.name.charAt(0).toUpperCase()}</span>
</div>
);
}
// ─── Folder icon (2×2 grid with glass-pill) ───────────────────────────────
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 (
<div
className={`relative w-10 h-10 flex items-center justify-center overflow-hidden glass-pill [transition:border-radius_0.2s] ${
isActive || isHovered ? 'rounded-[13px]' : 'rounded-[16px]'
}`}
style={borderColor ? { borderColor } : undefined}
>
<div className="grid grid-cols-2 gap-[2px] w-[28px] h-[28px] relative z-[1]">
{display.map((s) => (
<div key={s.id} className="w-[13px] h-[13px]">
<MiniSpaceIcon space={s} />
</div>
))}
{display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => (
<div key={`empty-${i}`} className="w-[13px] h-[13px] rounded-[3px] bg-white/[0.04]" />
))}
</div>
{remaining > 0 && (
<div className="absolute bottom-0 right-0 text-[7px] font-bold text-txt-tertiary bg-surface-base rounded-tl-sm px-0.5">
+{remaining}
</div>
)}
</div>
);
}
// ─── Folder context menu ──────────────────────────────────────────────────
function FolderContextMenu({ folder, x, y, onClose, onRename, onColorChange, onUngroup }: {
folder: SpaceFolder;
x: number;
y: number;
onClose: () => void;
onRename: () => void;
onColorChange: (color: string | null) => void;
onUngroup: () => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.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]);
const menuWidth = 200;
const menuHeight = 140;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
return ReactDOM.createPortal(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
style={{ left: clampedX, top: clampedY }}
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-txt-primary hover:bg-white/[0.06] transition-colors"
onClick={() => { onRename(); onClose(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
Rename Folder
</button>
<div className="px-3 py-1.5">
<p className="text-[11px] text-txt-tertiary mb-1.5">Folder Color</p>
<div className="flex gap-1.5">
<button
className={`w-5 h-5 rounded-full border-2 ${!folder.color ? 'border-white/40' : 'border-transparent'} bg-white/10`}
onClick={() => { onColorChange(null); onClose(); }}
title="Default"
/>
{FOLDER_COLORS.map((c) => (
<button
key={c.name}
className={`w-5 h-5 rounded-full border-2 ${folder.color === c.value ? 'border-white/40' : 'border-transparent'}`}
style={{ background: c.value }}
onClick={() => { onColorChange(c.value); onClose(); }}
title={c.name}
/>
))}
</div>
</div>
<div className="h-[1px] bg-white/[0.06] mx-2 my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-rose hover:bg-accent-rose/10 transition-colors"
onClick={() => { onUngroup(); onClose(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H8V4h12v12zM4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm12 6V8h-2v4H10v2h4v4h2v-4h4v-2h-4z" />
</svg>
Ungroup
</button>
</div>,
document.body,
);
}
// ─── 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<string>;
disconnectedOrigins: Set<string>;
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<HTMLDivElement>(anchorEl);
anchorRef.current = anchorEl;
const floatingRef = useRef<HTMLDivElement>(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(
<div
ref={floatingRef}
className="w-[220px] max-h-[360px] overflow-y-auto glass rounded-lg py-1.5 animate-in fade-in zoom-in-95 duration-100"
style={style}
>
{/* Folder header */}
{(folder.name || isRenaming) && (
<div className="px-3 pb-1.5 pt-0.5">
{isRenaming ? (
<input
autoFocus
className="w-full bg-surface-input text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-accent-mint/40"
defaultValue={folder.name || ''}
onBlur={(e) => onRename(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRename(e.currentTarget.value);
if (e.key === 'Escape') onClose();
}}
/>
) : (
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
{folder.name}
</span>
)}
</div>
)}
{/* 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 (
<button
key={space.id}
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 mx-1 rounded-md transition-colors ${
isDimmed ? 'opacity-40 saturate-50' : ''
} ${isActive ? 'bg-white/[0.10]' : 'hover:bg-white/[0.06]'}`}
style={{ width: 'calc(100% - 8px)' }}
draggable
onDragStart={(e) => onDragStart(e, space.id)}
onClick={() => {
onSpaceClick(space.id);
onClose();
}}
onContextMenu={(e) => {
onSpaceContextMenu(space.id, e);
onClose();
}}
>
{/* Space icon */}
<div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}>
{icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
alt=""
className="w-full h-full object-cover"
/>
) : (
<span className="text-[13px] font-bold text-white">{space.name.charAt(0).toUpperCase()}</span>
)}
</div>
{/* Name */}
<span className="text-sm text-txt-primary truncate flex-1 text-left">{space.name}</span>
{/* Federation badge */}
{isFederated && !isDimmed && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80 flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
)}
{/* Unread dot */}
{hasUnread && (
<div className="w-2 h-2 rounded-full bg-white flex-shrink-0" />
)}
</button>
);
})}
</div>,
document.body,
);
}
// ─── SpaceContextMenu ─────────────────────────────────────────────────────
function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) {
const menuRef = useRef<HTMLDivElement>(null);
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
const leaveSpace = useSpaceStore((s) => s.leaveSpace);
const generateInvite = useSpaceStore((s) => s.generateInvite);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const setShowDms = useUIStore((s) => s.setShowDms);
const addToast = useUIStore((s) => s.addToast);
const navigate = useNavigate();
const [showTransferModal, setShowTransferModal] = useState(false);
const [showLeaveConfirm, setShowLeaveConfirm] = useState(false);
const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? '');
// Close on click-outside and scroll
useEffect(() => {
if (showTransferModal || showLeaveConfirm) return; // Don't close when sub-dialog is open
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleScroll = () => onClose();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('scroll', handleScroll, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, showTransferModal, showLeaveConfirm]);
if (!space) return null;
const handleInvite = async () => {
try {
const code = await generateInvite(spaceId);
const origin = (space as any)._instanceOrigin || window.location.origin;
const url = `${origin}/invite/${code}`;
await navigator.clipboard.writeText(url);
addToast('Invite link copied to clipboard', 'success', 3000);
} catch {
addToast('Failed to generate invite', 'warning', 3000);
}
onClose();
};
if (showTransferModal) {
return (
<TransferOwnershipModal
spaceId={spaceId}
onClose={onClose}
/>
);
}
// Viewport-aware clamping — always 2 items (Invite + Transfer or Invite + Leave)
const menuWidth = 200;
const menuHeight = 2 * 32 + 8;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
return ReactDOM.createPortal(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
style={{ left: clampedX, top: clampedY }}
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-txt-primary hover:bg-white/[0.06] transition-colors"
onClick={handleInvite}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
Invite People
</button>
{isOwner ? (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-amber hover:bg-accent-amber/10 transition-colors"
onClick={() => setShowTransferModal(true)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 13h-3V3h-2v10H8l4 4 4-4zM4 19v2h16v-2H4z" />
</svg>
Transfer Ownership
</button>
) : (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-accent-rose hover:bg-accent-rose/10 transition-colors"
onClick={() => setShowLeaveConfirm(true)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg>
Leave Space
</button>
)}
<ConfirmDialog
isOpen={showLeaveConfirm}
onClose={() => setShowLeaveConfirm(false)}
onConfirm={() => {
if (currentSpaceId === spaceId) {
navigate('/channels/@me');
setCurrentSpace(null);
setShowDms(true);
}
leaveSpace(spaceId);
setShowLeaveConfirm(false);
onClose();
}}
title={`Leave ${space.name}`}
description="Are you sure you want to leave this space? You'll need a new invite to rejoin."
variant="danger"
confirmLabel="Leave"
/>
</div>,
document.body,
);
}
// ─── TransferOwnershipModal ───────────────────────────────────────────────
function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) {
const modalRef = useRef<HTMLDivElement>(null);
const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId));
const members = useSpaceStore((s) => s.members);
const currentUserId = useAuthStore((s) => s.user?.id);
const transferOwnership = useSpaceStore((s) => s.transferOwnership);
const addToast = useUIStore((s) => s.addToast);
const [search, setSearch] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [transferring, setTransferring] = useState(false);
// Filter members: exclude self, filter by search
const filteredMembers = useMemo(() => {
const spaceMembers = members.filter(m => m.userId !== currentUserId);
if (!search.trim()) return spaceMembers;
const q = search.toLowerCase();
return spaceMembers.filter(m =>
m.user.displayName?.toLowerCase().includes(q) ||
m.user.username.toLowerCase().includes(q)
);
}, [members, currentUserId, search]);
const selectedMember = selectedUserId ? members.find(m => m.userId === selectedUserId) : null;
// Close on click-outside and escape
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (selectedUserId) {
setSelectedUserId(null);
} else {
onClose();
}
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, selectedUserId]);
if (!space) return null;
const handleTransfer = async () => {
if (!selectedUserId) return;
setTransferring(true);
try {
await transferOwnership(spaceId, selectedUserId);
addToast(`Ownership transferred to ${selectedMember?.user.displayName || selectedMember?.user.username}`, 'success', 3000);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to transfer ownership', 'warning', 3000);
} finally {
setTransferring(false);
}
};
return ReactDOM.createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
ref={modalRef}
className="w-[380px] max-h-[480px] glass-modal rounded-xl flex flex-col animate-in fade-in zoom-in-95 duration-150"
>
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-white/[0.06]">
<h3 className="text-base font-semibold text-txt-primary">Transfer Ownership</h3>
<p className="text-xs text-txt-tertiary mt-0.5">
Choose a member to become the new owner of <span className="font-medium text-txt-secondary">{space.name}</span>
</p>
</div>
{selectedUserId && selectedMember ? (
/* Confirm step */
<div className="p-4 flex flex-col gap-4">
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/20">
<p className="text-sm text-txt-secondary">
Transfer ownership of <span className="font-semibold text-txt-primary">{space.name}</span> to{' '}
<span className="font-semibold text-txt-primary">{selectedMember.user.displayName || selectedMember.user.username}</span>?
</p>
<p className="text-xs text-txt-tertiary mt-1.5">You will become a regular member.</p>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setSelectedUserId(null)}
className="px-3 py-1.5 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
disabled={transferring}
>
Cancel
</button>
<button
onClick={handleTransfer}
disabled={transferring}
className="px-3 py-1.5 bg-accent-amber hover:bg-accent-amber/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{transferring ? 'Transferring...' : 'Transfer'}
</button>
</div>
</div>
) : (
/* Member list */
<>
<div className="px-3 pt-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search members..."
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{filteredMembers.length === 0 ? (
<p className="text-xs text-txt-tertiary text-center py-4">No members found</p>
) : (
filteredMembers.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
<button
key={member.userId}
onClick={() => setSelectedUserId(member.userId)}
className="w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-surface-input flex-shrink-0 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-txt-secondary">
{(member.user.displayName || member.user.username).charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex flex-col items-start min-w-0">
<span className="text-sm text-txt-primary truncate max-w-full">
{member.user.displayName || member.user.username}
</span>
{member.user.displayName && (
<span className="text-[11px] text-txt-tertiary truncate max-w-full">
{member.user.username}
</span>
)}
</div>
</button>
);
})
)}
</div>
</>
)}
</div>
</div>,
document.body,
);
}
// ─── 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 = (
<button onClick={onToggleFlyout}>
<FolderIcon spaces={folderSpaces} color={folder.color} isActive={isActive || isFlyoutOpen} isHovered={isHovered} />
</button>
);
return (
<div
ref={anchorRef}
className={`relative flex items-center mb-1.5 w-full justify-center ${isDragging ? 'opacity-50' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onContextMenu={onContextMenu}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDrop={onDrop}
>
{/* Drop indicators */}
{dropIndicator === 'before' && (
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)}
{dropIndicator === 'after' && (
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
)}
{dropIndicator === 'merge' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="w-12 h-12 rounded-[16px] ring-2 ring-accent-mint/60" />
</div>
)}
{/* Pill indicator */}
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`} />
</div>
{isFlyoutOpen ? (
iconContent
) : (
<Tooltip content={folder.name || `Folder (${folderSpaces.length})`} position="right" delay={300}>
{iconContent}
</Tooltip>
)}
</div>
);
}
// ─── SpaceSidebar (main component) ────────────────────────────────────────
export function SpaceSidebar() {
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const folders = useSpaceStore((s) => s.folders);
const spaceLayout = useSpaceStore((s) => s.spaceLayout);
const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const addToast = useUIStore((s) => s.addToast);
const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const instances = useInstanceStore((s) => s.instances);
const navigate = useNavigate();
const location = useLocation();
// Flyout state
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
const folderAnchorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Context menus
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
const [folderContextMenu, setFolderContextMenu] = useState<{ folder: SpaceFolder; x: number; y: number } | null>(null);
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
// DnD state
const [dragState, setDragState] = useState<{ dragId: string; dragType: 'space' | 'folder'; sourceFolderId?: string } | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after' | 'merge' } | null>(null);
const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ spaceId, x: e.clientX, y: e.clientY });
}, []);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
// Set of disconnected origins
const disconnectedOrigins = useMemo(() => {
const set = new Set<string>();
for (const inst of instances) {
if (inst.status === 'disconnected' || inst.status === 'error') {
set.add(inst.origin);
}
}
return set;
}, [instances]);
// Build space lookup map
const spaceMap = useMemo(() => {
const map = new Map<string, TaggedSpace>();
for (const s of spaces) map.set(s.id, s);
return map;
}, [spaces]);
// Build folder lookup map
const folderMap = useMemo(() => {
const map = new Map<string, SpaceFolder>();
for (const f of folders) map.set(f.id, f);
return map;
}, [folders]);
// Reconciled layout: merge spaceLayout with actual spaces and folders
const resolvedLayout = useMemo((): ResolvedItem[] => {
const memberSpaceIds = new Set(spaces.map(s => s.id));
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);
}
}
}
}
}
// Append any spaces not in the layout (newly joined, etc.)
for (const space of spaces) {
if (!accountedSpaceIds.has(space.id)) {
result.push({ type: 'space', space });
}
}
return result;
}, [spaceLayout, spaces, spaceMap, folderMap]);
// Compute which spaces have unread channels
const unreadSpaceIds = useMemo(() => {
const ids = new Set<string>();
for (const channelId of unreadChannels) {
const spaceId = channelToSpaceMap.get(channelId);
if (spaceId) ids.add(spaceId);
}
return ids;
}, [unreadChannels, channelToSpaceMap]);
// Check if any DM channels are unread
const hasDmUnread = useMemo(() => {
for (const dm of dmChannels) {
if (unreadChannels.has(dm.id)) return true;
}
return false;
}, [unreadChannels, dmChannels]);
const handleSpaceClick = useCallback((spaceId: string) => {
const space = spaceMap.get(spaceId);
const origin = space?._instanceOrigin;
if (origin && disconnectedOrigins.has(origin)) {
const inst = instances.find(i => i.origin === origin);
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
return;
}
setCurrentSpace(spaceId);
setShowDms(false);
navigate(`/channels/${spaceId}`);
}, [spaceMap, disconnectedOrigins, instances, addToast, setCurrentSpace, setShowDms, navigate]);
const handleDmClick = () => {
setShowDms(true);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleExploreClick = () => {
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/explore');
};
// ─── DnD handlers ──────────────────────────────────────────────────────
const handleDragStart = useCallback((e: React.DragEvent, id: string, type: 'space' | 'folder', sourceFolderId?: string) => {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
setDragState({ dragId: id, dragType: type, sourceFolderId });
}, []);
const handleDragOver = useCallback((e: React.DragEvent, targetId: string, targetType: 'space' | 'folder') => {
if (!dragState) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const relY = e.clientY - rect.top;
const height = rect.height;
let position: 'before' | 'after' | 'merge';
if (targetType === 'folder' || dragState.dragType === 'space') {
// Space items: top 25% = before, middle 50% = merge, bottom 25% = after
if (relY < height * 0.25) {
position = 'before';
} else if (relY > height * 0.75) {
position = 'after';
} else {
// Merge zone: only if dragging a space onto another space or folder
if (dragState.dragType === 'space' && dragState.dragId !== targetId) {
position = 'merge';
} else {
position = relY < height * 0.5 ? 'before' : 'after';
}
}
} else {
// Folder dragging: only before/after, no merge
position = relY < height * 0.5 ? 'before' : 'after';
}
setDropIndicator({ targetId, position });
}, [dragState]);
const handleDragEnd = useCallback(() => {
setDragState(null);
setDropIndicator(null);
}, []);
// Build layout items and folder payload from resolvedLayout for persistence
const buildLayoutPayload = useCallback((resolved: ResolvedItem[]) => {
const items: SpaceLayoutItem[] = [];
const folderPayload: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
for (const item of resolved) {
if (item.type === 'space') {
items.push({ t: 's', id: item.space.id });
} else {
items.push({ t: 'f', id: item.folder.id });
folderPayload[item.folder.id] = {
name: item.folder.name,
color: item.folder.color,
spaceIds: item.spaces.map(s => s.id),
};
}
}
return { items, folderPayload };
}, []);
const persistLayout = useCallback((resolved: ResolvedItem[]) => {
const { items, folderPayload } = buildLayoutPayload(resolved);
updateSpaceLayout(items, folderPayload);
}, [buildLayoutPayload, updateSpaceLayout]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!dragState || !dropIndicator) {
handleDragEnd();
return;
}
const { dragId, dragType, sourceFolderId } = dragState;
const { targetId, position } = dropIndicator;
// Don't drop on self
if (dragId === targetId && position !== 'merge') {
handleDragEnd();
return;
}
// Work with a mutable copy of the resolved layout
let newLayout = resolvedLayout.map(item => {
if (item.type === 'folder') {
return { ...item, spaces: [...item.spaces], folder: { ...item.folder } };
}
return { ...item };
}) as ResolvedItem[];
if (dragType === 'space') {
const dragSpace = spaceMap.get(dragId);
if (!dragSpace) { handleDragEnd(); return; }
// Remove from source
if (sourceFolderId) {
// Remove from folder
const folderItem = newLayout.find(i => i.type === 'folder' && i.folder.id === sourceFolderId) as (ResolvedItem & { type: 'folder' }) | undefined;
if (folderItem) {
folderItem.spaces = folderItem.spaces.filter(s => s.id !== dragId);
folderItem.folder = { ...folderItem.folder, spaceIds: folderItem.spaces.map(s => s.id) };
}
} else {
// Remove standalone
newLayout = newLayout.filter(item => !(item.type === 'space' && item.space.id === dragId));
}
if (position === 'merge') {
// Find the target
const targetIdx = newLayout.findIndex(item =>
(item.type === 'space' && item.space.id === targetId) ||
(item.type === 'folder' && item.folder.id === targetId)
);
if (targetIdx === -1) { handleDragEnd(); return; }
const targetItem = newLayout[targetIdx];
if (!targetItem) { handleDragEnd(); return; }
if (targetItem.type === 'space') {
// Create new folder with both spaces
const tempId = `new:${Date.now()}`;
const newFolder: ResolvedItem = {
type: 'folder',
folder: {
id: tempId,
userId: '',
name: null,
color: null,
position: 0,
spaceIds: [targetItem.space.id, dragSpace.id],
},
spaces: [targetItem.space, dragSpace],
};
newLayout[targetIdx] = newFolder;
} else if (targetItem.type === 'folder') {
// Add to existing folder
targetItem.spaces.push(dragSpace);
targetItem.folder = { ...targetItem.folder, spaceIds: targetItem.spaces.map(s => s.id) };
}
} else {
// Reorder: insert before or after target
const targetIdx = newLayout.findIndex(item =>
(item.type === 'space' && item.space.id === targetId) ||
(item.type === 'folder' && item.folder.id === targetId)
);
if (targetIdx === -1) { handleDragEnd(); return; }
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
const newItem: ResolvedItem = { type: 'space', space: dragSpace };
newLayout.splice(insertIdx, 0, newItem);
}
// Dissolve folders with < 2 members
newLayout = newLayout.flatMap(item => {
if (item.type === 'folder' && item.spaces.length < 2) {
if (item.spaces.length === 1 && item.spaces[0]) {
return [{ type: 'space' as const, space: item.spaces[0] }];
}
return []; // 0 members, remove entirely
}
return [item];
});
} else if (dragType === 'folder') {
// Remove the folder from its current position
const dragIdx = newLayout.findIndex(i => i.type === 'folder' && i.folder.id === dragId);
if (dragIdx === -1) { handleDragEnd(); return; }
const dragItem = newLayout.splice(dragIdx, 1)[0];
if (!dragItem) { handleDragEnd(); return; }
// Insert at target position
const targetIdx = newLayout.findIndex(item =>
(item.type === 'space' && item.space.id === targetId) ||
(item.type === 'folder' && item.folder.id === targetId)
);
if (targetIdx === -1) {
newLayout.push(dragItem);
} else {
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
newLayout.splice(insertIdx, 0, dragItem);
}
}
persistLayout(newLayout);
handleDragEnd();
}, [dragState, dropIndicator, resolvedLayout, spaceMap, handleDragEnd, persistLayout]);
// ─── Folder actions ──────────────────────────────────────────────────
const handleFolderRename = useCallback((folderId: string, name: string) => {
const newLayout = resolvedLayout.map(item => {
if (item.type === 'folder' && item.folder.id === folderId) {
return { ...item, folder: { ...item.folder, name: name.trim() || null } };
}
return item;
});
persistLayout(newLayout);
setRenamingFolderId(null);
}, [resolvedLayout, persistLayout]);
const handleFolderColorChange = useCallback((folderId: string, color: string | null) => {
const newLayout = resolvedLayout.map(item => {
if (item.type === 'folder' && item.folder.id === folderId) {
return { ...item, folder: { ...item.folder, color } };
}
return item;
});
persistLayout(newLayout);
}, [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 }));
}
return [item];
});
persistLayout(newLayout);
}, [resolvedLayout, persistLayout]);
// ─── Helpers for rendering ──────────────────────────────────────────
const getFederationInfo = useCallback((space: TaggedSpace) => {
const origin = space._instanceOrigin;
const isFederated = !!origin;
const isDimmed = isFederated && disconnectedOrigins.has(origin);
let tooltipText = space.name;
if (isFederated) {
try {
const hostLabel = new URL(origin).host;
tooltipText = `${space.name} \u00b7 ${hostLabel}`;
} catch { /* ignore */ }
}
return { isFederated, isDimmed, tooltipText };
}, [disconnectedOrigins]);
// Check if a folder has any unread spaces
const folderHasUnread = useCallback((folderSpaces: TaggedSpace[]) => {
return folderSpaces.some(s => unreadSpaceIds.has(s.id));
}, [unreadSpaceIds]);
// Check if folder has any active space
const folderHasActive = useCallback((folderSpaces: TaggedSpace[]) => {
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 (
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<SidebarItem
id="@me"
name="Direct Messages"
active={showDms}
onClick={handleDmClick}
type="dm"
hasUnread={hasDmUnread}
/>
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
{/* Unified space list (ordered by user layout) */}
{resolvedLayout.map((item) => {
if (item.type === 'space') {
const { isFederated, isDimmed, tooltipText } = getFederationInfo(item.space);
return (
<SidebarItem
key={item.space.id}
id={item.space.id}
name={item.space.name}
icon={item.space.icon}
avatarColor={item.space.avatarColor}
active={currentSpaceId === item.space.id}
onClick={() => handleSpaceClick(item.space.id)}
onContextMenu={(e) => handleSpaceContextMenu(item.space.id, e)}
hasUnread={unreadSpaceIds.has(item.space.id)}
dimmed={isDimmed}
federationBadge={isFederated}
federationDisconnected={isDimmed}
tooltipText={tooltipText}
draggable
onDragStart={(e) => handleDragStart(e, item.space.id, 'space')}
onDragOver={(e) => handleDragOver(e, item.space.id, 'space')}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
isDragging={dragState?.dragType === 'space' && dragState.dragId === item.space.id}
dropIndicator={dropIndicator?.targetId === item.space.id ? dropIndicator.position : null}
/>
);
}
// Folder — always a single icon slot
const { folder, spaces: folderSpaces } = item;
const isActive = folderHasActive(folderSpaces);
const hasUnread = folderHasUnread(folderSpaces);
const isFlyoutOpen = openFolderId === folder.id;
return (
<FolderSlot
key={`folder-${folder.id}`}
folder={folder}
folderSpaces={folderSpaces}
isActive={isActive}
hasUnread={hasUnread}
isFlyoutOpen={isFlyoutOpen}
isDragging={dragState?.dragType === 'folder' && dragState.dragId === folder.id}
dropIndicator={dropIndicator?.targetId === folder.id ? dropIndicator.position : null}
onToggleFlyout={() => setOpenFolderId(isFlyoutOpen ? null : folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
}}
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
anchorRef={(el) => {
if (el) folderAnchorRefs.current.set(folder.id, el);
else folderAnchorRefs.current.delete(folder.id);
}}
/>
);
})}
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
<SidebarItem
id="add-space"
name="Add a Space"
active={false}
onClick={() => openModal('createSpace')}
type="action"
actionType="add"
/>
<SidebarItem
id="join-space"
name="Join a Space"
active={false}
onClick={() => openModal('joinSpace')}
type="action"
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Spaces"
active={location.pathname === '/explore'}
onClick={handleExploreClick}
type="action"
actionType="explore"
/>
{contextMenu && (
<SpaceContextMenu
spaceId={contextMenu.spaceId}
x={contextMenu.x}
y={contextMenu.y}
onClose={closeContextMenu}
/>
)}
{folderContextMenu && (
<FolderContextMenu
folder={folderContextMenu.folder}
x={folderContextMenu.x}
y={folderContextMenu.y}
onClose={() => setFolderContextMenu(null)}
onRename={() => {
setRenamingFolderId(folderContextMenu.folder.id);
setOpenFolderId(folderContextMenu.folder.id);
}}
onColorChange={(color) => handleFolderColorChange(folderContextMenu.folder.id, color)}
onUngroup={() => handleUngroup(folderContextMenu.folder.id)}
/>
)}
{openFolderId && (() => {
const folderItem = resolvedLayout.find(
(i): i is ResolvedItem & { type: 'folder' } =>
i.type === 'folder' && i.folder.id === openFolderId
);
const anchor = folderAnchorRefs.current.get(openFolderId);
if (!folderItem || !anchor) return null;
return (
<FolderFlyout
folder={folderItem.folder}
spaces={folderItem.spaces}
anchorEl={anchor}
currentSpaceId={currentSpaceId}
unreadSpaceIds={unreadSpaceIds}
disconnectedOrigins={disconnectedOrigins}
renamingFolderId={renamingFolderId}
onClose={() => setOpenFolderId(null)}
onSpaceClick={handleSpaceClick}
onSpaceContextMenu={handleSpaceContextMenu}
onRename={(name) => handleFolderRename(openFolderId, name)}
onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)}
/>
);
})()}
</nav>
);
}