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.
This commit is contained in:
@@ -12,28 +12,13 @@ import { ConfirmDialog } from '../ui/ConfirmDialog';
|
|||||||
import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||||
|
|
||||||
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
|
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
|
||||||
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
// ─── Resolved layout types ─────────────────────────────────────────────────
|
// ─── Resolved layout types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
type ResolvedItem =
|
type ResolvedItem =
|
||||||
| { type: 'space'; space: TaggedSpace }
|
| { type: 'space'; space: TaggedSpace }
|
||||||
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[]; collapsed: boolean };
|
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
|
||||||
|
|
||||||
// ─── localStorage keys ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const COLLAPSED_KEY = 'backspace:collapsed-space-folders';
|
|
||||||
|
|
||||||
function getCollapsedFolders(): Set<string> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(COLLAPSED_KEY);
|
|
||||||
if (raw) return new Set(JSON.parse(raw));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCollapsedFolders(ids: Set<string>): void {
|
|
||||||
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...ids]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Folder color presets ─────────────────────────────────────────────────
|
// ─── 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 display = spaces.slice(0, 4);
|
||||||
const remaining = spaces.length - 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 (
|
return (
|
||||||
<div className="relative w-10 h-10 rounded-[13px] flex items-center justify-center overflow-hidden" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
<div
|
||||||
{color && <div className="absolute inset-0 opacity-[0.12]" style={{ background: color }} />}
|
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]">
|
<div className="grid grid-cols-2 gap-[2px] w-[28px] h-[28px] relative z-[1]">
|
||||||
{display.map((s) => (
|
{display.map((s) => (
|
||||||
<div key={s.id} className="w-[13px] h-[13px]">
|
<div key={s.id} className="w-[13px] h-[13px]">
|
||||||
<MiniSpaceIcon space={s} />
|
<MiniSpaceIcon space={s} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Fill empty cells */}
|
|
||||||
{display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => (
|
{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 key={`empty-${i}`} className="w-[13px] h-[13px] rounded-[3px] bg-white/[0.04]" />
|
||||||
))}
|
))}
|
||||||
@@ -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<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 ─────────────────────────────────────────────────────
|
// ─── SpaceContextMenu ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) {
|
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 = (
|
||||||
|
<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) ────────────────────────────────────────
|
// ─── SpaceSidebar (main component) ────────────────────────────────────────
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
@@ -660,22 +891,9 @@ export function SpaceSidebar() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Collapsed folders state
|
// Flyout state
|
||||||
const [collapsedFolderIds, setCollapsedFolderIdsState] = useState(getCollapsedFolders);
|
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||||
const setCollapsedFolderIds = useCallback((ids: Set<string>) => {
|
const folderAnchorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Context menus
|
// Context menus
|
||||||
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null);
|
||||||
@@ -743,7 +961,6 @@ export function SpaceSidebar() {
|
|||||||
type: 'folder',
|
type: 'folder',
|
||||||
folder,
|
folder,
|
||||||
spaces: folderSpaces,
|
spaces: folderSpaces,
|
||||||
collapsed: collapsedFolderIds.has(folder.id),
|
|
||||||
});
|
});
|
||||||
for (const s of folderSpaces) accountedSpaceIds.add(s.id);
|
for (const s of folderSpaces) accountedSpaceIds.add(s.id);
|
||||||
}
|
}
|
||||||
@@ -760,7 +977,7 @@ export function SpaceSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [spaceLayout, spaces, spaceMap, folderMap, collapsedFolderIds]);
|
}, [spaceLayout, spaces, spaceMap, folderMap]);
|
||||||
|
|
||||||
// Compute which spaces have unread channels
|
// Compute which spaces have unread channels
|
||||||
const unreadSpaceIds = useMemo(() => {
|
const unreadSpaceIds = useMemo(() => {
|
||||||
@@ -943,7 +1160,6 @@ export function SpaceSidebar() {
|
|||||||
spaceIds: [targetItem.space.id, dragSpace.id],
|
spaceIds: [targetItem.space.id, dragSpace.id],
|
||||||
},
|
},
|
||||||
spaces: [targetItem.space, dragSpace],
|
spaces: [targetItem.space, dragSpace],
|
||||||
collapsed: false,
|
|
||||||
};
|
};
|
||||||
newLayout[targetIdx] = newFolder;
|
newLayout[targetIdx] = newFolder;
|
||||||
} else if (targetItem.type === 'folder') {
|
} else if (targetItem.type === 'folder') {
|
||||||
@@ -1023,6 +1239,7 @@ export function SpaceSidebar() {
|
|||||||
}, [resolvedLayout, persistLayout]);
|
}, [resolvedLayout, persistLayout]);
|
||||||
|
|
||||||
const handleUngroup = useCallback((folderId: string) => {
|
const handleUngroup = useCallback((folderId: string) => {
|
||||||
|
setOpenFolderId(null);
|
||||||
const newLayout = resolvedLayout.flatMap(item => {
|
const newLayout = resolvedLayout.flatMap(item => {
|
||||||
if (item.type === 'folder' && item.folder.id === folderId) {
|
if (item.type === 'folder' && item.folder.id === folderId) {
|
||||||
return item.spaces.map(s => ({ type: 'space' as const, space: s }));
|
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;
|
return currentSpaceId ? folderSpaces.some(s => s.id === currentSpaceId) : false;
|
||||||
}, [currentSpaceId]);
|
}, [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 (
|
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 }}>
|
<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
|
<SidebarItem
|
||||||
@@ -1101,152 +1325,36 @@ export function SpaceSidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder
|
// Folder — always a single icon slot
|
||||||
const { folder, spaces: folderSpaces, collapsed } = item;
|
const { folder, spaces: folderSpaces } = item;
|
||||||
const isActive = folderHasActive(folderSpaces);
|
const isActive = folderHasActive(folderSpaces);
|
||||||
const hasUnread = folderHasUnread(folderSpaces);
|
const hasUnread = folderHasUnread(folderSpaces);
|
||||||
|
const isFlyoutOpen = openFolderId === folder.id;
|
||||||
|
|
||||||
if (collapsed) {
|
|
||||||
// Collapsed folder — single icon
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`folder-${folder.id}`}
|
|
||||||
className={`relative flex items-center mb-1.5 w-full justify-center ${
|
|
||||||
dragState?.dragType === 'folder' && dragState.dragId === folder.id ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
|
|
||||||
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drop indicators */}
|
|
||||||
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'before' && (
|
|
||||||
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
|
||||||
)}
|
|
||||||
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'after' && (
|
|
||||||
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
|
||||||
)}
|
|
||||||
{dropIndicator?.targetId === folder.id && dropIndicator.position === '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 ${
|
|
||||||
isActive ? 'h-8' : hasUnread ? 'h-2' : 'h-2 scale-0'
|
|
||||||
} w-1`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip content={folder.name || `Folder (${folderSpaces.length})`} position="right" delay={300}>
|
|
||||||
<button onClick={() => toggleFolderCollapse(folder.id)}>
|
|
||||||
<CollapsedFolderIcon spaces={folderSpaces} color={folder.color} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded folder
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FolderSlot
|
||||||
key={`folder-${folder.id}`}
|
key={`folder-${folder.id}`}
|
||||||
className="w-full flex flex-col items-center"
|
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')}
|
onDragOver={(e) => handleDragOver(e, folder.id, 'folder')}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
anchorRef={(el) => {
|
||||||
{/* Top separator with folder toggle */}
|
if (el) folderAnchorRefs.current.set(folder.id, el);
|
||||||
<div
|
else folderAnchorRefs.current.delete(folder.id);
|
||||||
className="flex items-center justify-center w-full my-1 cursor-pointer"
|
}}
|
||||||
draggable
|
/>
|
||||||
onDragStart={(e) => handleDragStart(e, folder.id, 'folder')}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFolderContextMenu({ folder, x: e.clientX, y: e.clientY });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drop indicator for folder header */}
|
|
||||||
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'before' && (
|
|
||||||
<div className="absolute top-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
|
||||||
)}
|
|
||||||
<Tooltip content={folder.name || `Folder (${folderSpaces.length})`} position="right" delay={300}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleFolderCollapse(folder.id)}
|
|
||||||
className="relative flex items-center justify-center w-12"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-[2px] rounded-full" style={{ background: folder.color || 'rgba(255,255,255,0.08)' }} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folder rename input */}
|
|
||||||
{renamingFolderId === folder.id && (
|
|
||||||
<div className="w-full px-3 mb-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="w-full bg-surface-input text-[10px] text-txt-primary rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-accent-mint/40"
|
|
||||||
defaultValue={folder.name || ''}
|
|
||||||
onBlur={(e) => handleFolderRename(folder.id, e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleFolderRename(folder.id, e.currentTarget.value);
|
|
||||||
if (e.key === 'Escape') setRenamingFolderId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Folder member spaces */}
|
|
||||||
<div className="relative w-full flex flex-col items-center">
|
|
||||||
{/* Left accent line */}
|
|
||||||
<div
|
|
||||||
className="absolute left-[10px] top-0 bottom-0 w-[2px] rounded-full"
|
|
||||||
style={{ background: folder.color || 'rgba(255,255,255,0.06)' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{folderSpaces.map((space) => {
|
|
||||||
const { isFederated, isDimmed, tooltipText } = getFederationInfo(space);
|
|
||||||
return (
|
|
||||||
<SidebarItem
|
|
||||||
key={space.id}
|
|
||||||
id={space.id}
|
|
||||||
name={space.name}
|
|
||||||
icon={space.icon}
|
|
||||||
avatarColor={space.avatarColor}
|
|
||||||
active={currentSpaceId === space.id}
|
|
||||||
onClick={() => handleSpaceClick(space.id)}
|
|
||||||
onContextMenu={(e) => handleSpaceContextMenu(space.id, e)}
|
|
||||||
hasUnread={unreadSpaceIds.has(space.id)}
|
|
||||||
dimmed={isDimmed}
|
|
||||||
federationBadge={isFederated}
|
|
||||||
federationDisconnected={isDimmed}
|
|
||||||
tooltipText={tooltipText}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, space.id, 'space', folder.id)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, space.id, 'space')}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
isDragging={dragState?.dragType === 'space' && dragState.dragId === space.id}
|
|
||||||
dropIndicator={dropIndicator?.targetId === space.id ? dropIndicator.position : null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom separator */}
|
|
||||||
<div className="flex items-center justify-center w-full my-1">
|
|
||||||
{dropIndicator?.targetId === folder.id && dropIndicator.position === 'after' && (
|
|
||||||
<div className="absolute bottom-0 left-3 right-3 h-[2px] bg-accent-mint rounded-full z-10" />
|
|
||||||
)}
|
|
||||||
<div className="w-8 h-[2px] rounded-full" style={{ background: folder.color || 'rgba(255,255,255,0.08)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -1294,11 +1402,39 @@ export function SpaceSidebar() {
|
|||||||
x={folderContextMenu.x}
|
x={folderContextMenu.x}
|
||||||
y={folderContextMenu.y}
|
y={folderContextMenu.y}
|
||||||
onClose={() => setFolderContextMenu(null)}
|
onClose={() => setFolderContextMenu(null)}
|
||||||
onRename={() => setRenamingFolderId(folderContextMenu.folder.id)}
|
onRename={() => {
|
||||||
|
setRenamingFolderId(folderContextMenu.folder.id);
|
||||||
|
setOpenFolderId(folderContextMenu.folder.id);
|
||||||
|
}}
|
||||||
onColorChange={(color) => handleFolderColorChange(folderContextMenu.folder.id, color)}
|
onColorChange={(color) => handleFolderColorChange(folderContextMenu.folder.id, color)}
|
||||||
onUngroup={() => handleUngroup(folderContextMenu.folder.id)}
|
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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user