mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat: support layout custom sort and fix copy (#13812)
* fix: menu locale keys * feat: support resort sidebar * feat: add lock to middle messages * feat: add memory menu and default hidden * fix: lint error * fix: legacy secion order * chore: add test cases * chore: remove top zone * feat: custom sidebar reorder * chore: fix sidebar items
This commit is contained in:
parent
41efd16bba
commit
fd0d846975
15 changed files with 823 additions and 306 deletions
|
|
@ -343,14 +343,15 @@
|
|||
"mail.support": "Email Support",
|
||||
"more": "More",
|
||||
"navPanel.agent": "Agents",
|
||||
"navPanel.customizeSidebar": "Customize sidebar",
|
||||
"navPanel.customizeSidebar": "Customize Sidebar",
|
||||
"navPanel.displayItems": "Display Items",
|
||||
"navPanel.hidden": "Hidden",
|
||||
"navPanel.hideSection": "Hide section",
|
||||
"navPanel.hideSection": "Hide Section",
|
||||
"navPanel.library": "Library",
|
||||
"navPanel.moveDown": "Move down",
|
||||
"navPanel.moveUp": "Move up",
|
||||
"navPanel.moveDown": "Move Down",
|
||||
"navPanel.moveUp": "Move Up",
|
||||
"navPanel.pinned": "Pinned",
|
||||
"navPanel.resetDefault": "Reset to Default",
|
||||
"navPanel.searchAgent": "Search Agent...",
|
||||
"navPanel.searchRecent": "Search Recent...",
|
||||
"navPanel.searchResultEmpty": "No search results found",
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@
|
|||
"navPanel.moveDown": "下移",
|
||||
"navPanel.moveUp": "上移",
|
||||
"navPanel.pinned": "已固定",
|
||||
"navPanel.resetDefault": "恢复默认",
|
||||
"navPanel.searchAgent": "搜索助理…",
|
||||
"navPanel.searchRecent": "搜索最近…",
|
||||
"navPanel.searchResultEmpty": "暂无搜索结果",
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@
|
|||
"@codesandbox/sandpack-react": "^2.20.0",
|
||||
"@discordjs/rest": "^2.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ export const useNavLayout = (): NavLayout => {
|
|||
title: t('tab.resource'),
|
||||
url: '/resource',
|
||||
},
|
||||
{
|
||||
icon: getRouteById('memory')!.icon,
|
||||
key: SidebarTabKey.Memory,
|
||||
title: t('tab.memory'),
|
||||
url: '/memory',
|
||||
},
|
||||
] as NavItem[],
|
||||
[t, showMarket],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -424,14 +424,15 @@ export default {
|
|||
'mail.support': 'Email Support',
|
||||
'more': 'More',
|
||||
'navPanel.agent': 'Agents',
|
||||
'navPanel.customizeSidebar': 'Customize sidebar',
|
||||
'navPanel.customizeSidebar': 'Customize Sidebar',
|
||||
'navPanel.displayItems': 'Display Items',
|
||||
'navPanel.resetDefault': 'Reset to Default',
|
||||
'navPanel.hidden': 'Hidden',
|
||||
'navPanel.hideSection': 'Hide section',
|
||||
'navPanel.hideSection': 'Hide Section',
|
||||
'navPanel.library': 'Library',
|
||||
'navPanel.moveDown': 'Move down',
|
||||
'navPanel.moveDown': 'Move Down',
|
||||
'navPanel.pinned': 'Pinned',
|
||||
'navPanel.moveUp': 'Move up',
|
||||
'navPanel.moveUp': 'Move Up',
|
||||
'navPanel.show': 'Show',
|
||||
'navPanel.visible': 'Visible',
|
||||
'navPanel.searchAgent': 'Search Agent...',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { reorderSidebarItems } from '@/store/global/selectors/systemStatus';
|
||||
|
||||
import { useCreateMenuItems } from '../../hooks';
|
||||
|
||||
|
|
@ -19,30 +20,27 @@ export const useAgentActionsDropdownMenu = ({
|
|||
}: AgentActionsDropdownMenuProps): MenuProps['items'] => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [agentPageSize, sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore(
|
||||
(s) => [
|
||||
systemStatusSelectors.agentPageSize(s),
|
||||
systemStatusSelectors.sidebarSectionOrder(s),
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
],
|
||||
);
|
||||
const [agentPageSize, sidebarItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.agentPageSize(s),
|
||||
systemStatusSelectors.sidebarItems(s),
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
|
||||
const visibleOrder = sidebarSectionOrder.filter((k) => !hiddenSections.includes(k));
|
||||
const visibleIndex = visibleOrder.indexOf('agent');
|
||||
const visibleItems = sidebarItems.filter((k) => !hiddenSections.includes(k));
|
||||
const visibleIndex = visibleItems.indexOf('agent');
|
||||
const isFirst = visibleIndex === 0;
|
||||
const isLast = visibleIndex === visibleOrder.length - 1;
|
||||
const isLast = visibleIndex === visibleItems.length - 1;
|
||||
|
||||
const moveSection = useCallback(
|
||||
(direction: 'up' | 'down') => {
|
||||
const newOrder = [...sidebarSectionOrder];
|
||||
const idx = newOrder.indexOf('agent');
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= newOrder.length) return;
|
||||
[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]];
|
||||
updateSystemStatus({ sidebarSectionOrder: newOrder });
|
||||
const idx = sidebarItems.indexOf('agent');
|
||||
if (idx === -1) return;
|
||||
const next = reorderSidebarItems(sidebarItems, idx, direction === 'up' ? idx - 1 : idx + 1);
|
||||
if (next === sidebarItems) return;
|
||||
updateSystemStatus({ sidebarItems: next });
|
||||
},
|
||||
[sidebarSectionOrder, updateSystemStatus],
|
||||
[sidebarItems, updateSystemStatus],
|
||||
);
|
||||
|
||||
// Create menu items
|
||||
|
|
@ -104,7 +102,7 @@ export const useAgentActionsDropdownMenu = ({
|
|||
isFirst,
|
||||
isLast,
|
||||
moveSection,
|
||||
visibleOrder.length,
|
||||
visibleItems.length,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,94 +1,12 @@
|
|||
import { type MenuProps } from '@lobehub/ui';
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
|
||||
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
|
||||
import { useNavLayout } from '@/hooks/useNavLayout';
|
||||
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { isModifierClick } from '@/utils/navigation';
|
||||
import { prefetchRoute } from '@/utils/router';
|
||||
import { memo } from 'react';
|
||||
|
||||
/**
|
||||
* BottomMenu items (Community, Resources) are now rendered by Body
|
||||
* based on sidebarSectionOrder for drag-and-drop reordering support.
|
||||
* This component is kept as a placeholder for potential future use.
|
||||
*/
|
||||
const BottomMenu = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
const tab = useActiveTabKey();
|
||||
const navigate = useNavigate();
|
||||
const { bottomMenuItems: items } = useNavLayout();
|
||||
const [hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
|
||||
const hideSection = useCallback(
|
||||
(key: string) => {
|
||||
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
|
||||
},
|
||||
[hiddenSections, updateSystemStatus],
|
||||
);
|
||||
|
||||
const getContextMenuItems = useCallback(
|
||||
(key: string): MenuProps['items'] => [
|
||||
{
|
||||
icon: <Icon icon={EyeOffIcon} />,
|
||||
key: 'hideSection',
|
||||
label: t('navPanel.hideSection'),
|
||||
onClick: () => hideSection(key),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={SlidersHorizontalIcon} />,
|
||||
key: 'customizeSidebar',
|
||||
label: t('navPanel.customizeSidebar'),
|
||||
onClick: () => openCustomizeSidebarModal(),
|
||||
},
|
||||
],
|
||||
[t, hideSection],
|
||||
);
|
||||
|
||||
const visibleItems = items.filter((item) => !item.hidden && !hiddenSections.includes(item.key));
|
||||
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
gap={1}
|
||||
paddingBlock={4}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.url!}
|
||||
onMouseEnter={() => prefetchRoute(item.url!)}
|
||||
onClick={(e) => {
|
||||
if (isModifierClick(e)) return;
|
||||
e.preventDefault();
|
||||
navigate(item.url!);
|
||||
}}
|
||||
>
|
||||
<NavItem
|
||||
active={tab === item.key}
|
||||
contextMenuItems={getContextMenuItems(item.key)}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
actions={
|
||||
<DropdownMenu items={getContextMenuItems(item.key)} nativeButton={false}>
|
||||
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
export default BottomMenu;
|
||||
|
|
|
|||
|
|
@ -1,34 +1,68 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, Block, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import {
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ActionIcon, Button, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import { Modal } from '@lobehub/ui/base-ui';
|
||||
import { Divider } from 'antd';
|
||||
import { Eye, EyeOff, PinIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { Eye, EyeOff, GripVertical, PinIcon, RotateCcw } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getRouteById } from '@/config/routes';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { SIDEBAR_ACCORDION_KEYS } from '@/store/global/selectors/systemStatus';
|
||||
|
||||
// Top nav items (Pages)
|
||||
const TOP_NAV_ITEMS: { key: string; labelKey: string; routeId?: string }[] = [
|
||||
{ key: 'pages', labelKey: 'tab.pages', routeId: 'page' },
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACCORDION_GROUP_ID = 'accordion-group';
|
||||
|
||||
interface SidebarItemConfig {
|
||||
alwaysVisible?: boolean;
|
||||
id: string;
|
||||
labelKey: string;
|
||||
routeId?: string;
|
||||
}
|
||||
|
||||
const ALL_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'pages', labelKey: 'tab.pages', routeId: 'page' },
|
||||
{ id: 'recents', labelKey: 'recents' },
|
||||
{ alwaysVisible: true, id: 'agent', labelKey: 'navPanel.agent' },
|
||||
{ id: 'community', labelKey: 'tab.community', routeId: 'community' },
|
||||
{ id: 'resource', labelKey: 'tab.resource', routeId: 'resource' },
|
||||
{ id: 'memory', labelKey: 'tab.memory', routeId: 'memory' },
|
||||
];
|
||||
|
||||
// Accordion sections (Recents, Agents)
|
||||
// `alwaysVisible` sections cannot be hidden by the user
|
||||
const SECTION_ITEMS: { alwaysVisible?: boolean; icon?: any; key: string; labelKey: string }[] = [
|
||||
{ key: 'recents', labelKey: 'recents' },
|
||||
{ alwaysVisible: true, key: 'agent', labelKey: 'navPanel.agent' },
|
||||
];
|
||||
const ITEM_MAP = new Map(ALL_SIDEBAR_ITEMS.map((item) => [item.id, item]));
|
||||
|
||||
// Bottom menu items (Community, Resources)
|
||||
const BOTTOM_ITEMS: { key: string; labelKey: string; routeId?: string }[] = [
|
||||
{ key: 'community', labelKey: 'tab.community', routeId: 'community' },
|
||||
{ key: 'resource', labelKey: 'tab.resource', routeId: 'resource' },
|
||||
];
|
||||
const isAccordionKey = (id: string) => SIDEBAR_ACCORDION_KEYS.has(id);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useCustomizeSidebarModalStore = create<{
|
||||
open: boolean;
|
||||
|
|
@ -41,118 +75,338 @@ const useCustomizeSidebarModalStore = create<{
|
|||
export const openCustomizeSidebarModal = () =>
|
||||
useCustomizeSidebarModalStore.getState().setOpen(true);
|
||||
|
||||
const SectionRow = memo<{
|
||||
alwaysVisible?: boolean;
|
||||
icon?: any;
|
||||
isHidden: boolean;
|
||||
label: string;
|
||||
pinnedTooltip?: string;
|
||||
toggleTooltip?: string;
|
||||
onToggle: () => void;
|
||||
}>(({ label, icon, isHidden, alwaysVisible, pinnedTooltip, toggleTooltip, onToggle }) => (
|
||||
<Block style={{ opacity: isHidden ? 0.5 : 1 }} variant={isHidden ? 'filled' : 'borderless'}>
|
||||
<Flexbox horizontal align={'center'} height={40} justify={'space-between'} paddingInline={8}>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
accordionGroup: css`
|
||||
margin-inline: -5px;
|
||||
padding: 4px;
|
||||
border: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
`,
|
||||
item: css`
|
||||
height: 40px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
transition: background 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
itemDragging: css`
|
||||
opacity: 0;
|
||||
`,
|
||||
overlay: css`
|
||||
height: 40px;
|
||||
padding-inline: 8px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
background: ${cssVar.colorBgElevated};
|
||||
box-shadow: ${cssVar.boxShadowSecondary};
|
||||
`,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SortableItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SortableItem = memo<{
|
||||
hiddenSections: string[];
|
||||
id: string;
|
||||
onToggle: (key: string) => void;
|
||||
}>(({ id, hiddenSections, onToggle }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const item = ITEM_MAP.get(id);
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const route = item.routeId ? getRouteById(item.routeId) : undefined;
|
||||
const isHidden = !item.alwaysVisible && hiddenSections.includes(id);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={isDragging ? cx(styles.item, styles.itemDragging) : styles.item}
|
||||
gap={4}
|
||||
justify={'space-between'}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
opacity: isHidden && !isDragging ? 0.5 : undefined,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
{...attributes}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{icon && <Icon icon={icon} size={18} />}
|
||||
<Text>{label}</Text>
|
||||
<Flexbox
|
||||
ref={setActivatorNodeRef}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab', flexShrink: 0, touchAction: 'none' }}
|
||||
{...listeners}
|
||||
>
|
||||
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
</Flexbox>
|
||||
{route?.icon && <Icon icon={route.icon} size={18} />}
|
||||
<Text>{t(item.labelKey as any)}</Text>
|
||||
</Flexbox>
|
||||
{alwaysVisible ? (
|
||||
<Tooltip title={pinnedTooltip}>
|
||||
{item.alwaysVisible ? (
|
||||
<Tooltip title={t('navPanel.pinned' as any)}>
|
||||
<ActionIcon icon={PinIcon} size={'small'} style={{ cursor: 'default', opacity: 0.45 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={toggleTooltip}>
|
||||
<ActionIcon icon={isHidden ? EyeOff : Eye} size={'small'} onClick={onToggle} />
|
||||
<Tooltip title={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}>
|
||||
<ActionIcon icon={isHidden ? EyeOff : Eye} size={'small'} onClick={() => onToggle(id)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Block>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
const CustomizeSidebarContent = memo(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// AccordionGroup — a non-draggable slot at the outer level that wraps a nested
|
||||
// SortableContext for accordion items. Registers with useSortable so other outer
|
||||
// items can reorder relative to its position, but has no drag activator of its own.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AccordionGroup = memo<{ children: React.ReactNode }>(({ children }) => {
|
||||
const { setNodeRef, transform, transition } = useSortable({ id: ACCORDION_GROUP_ID });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.accordionGroup}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
<Flexbox gap={2}>{children}</Flexbox>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag overlay item (static, no sortable hooks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OverlayItem = memo<{ id: string }>(({ id }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.sidebarSectionOrder(s),
|
||||
// Accordion group overlay: render a compact representation
|
||||
if (id === ACCORDION_GROUP_ID) {
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.overlay} gap={8}>
|
||||
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
<Text>{t('navPanel.agent' as any)}</Text>
|
||||
<Text type={'secondary'}>+ {t('recents' as any)}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
const item = ITEM_MAP.get(id);
|
||||
if (!item) return null;
|
||||
const route = item.routeId ? getRouteById(item.routeId) : undefined;
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.overlay} gap={8}>
|
||||
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
{route?.icon && <Icon icon={route.icon} size={18} />}
|
||||
<Text>{t(item.labelKey as any)}</Text>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Flatten outer list (with ACCORDION_GROUP_ID placeholder) + inner accordion items → full list. */
|
||||
const flattenItems = (outer: string[], inner: string[]): string[] =>
|
||||
outer.flatMap((id) => (id === ACCORDION_GROUP_ID ? inner : [id]));
|
||||
|
||||
const CustomizeSidebarContent = memo(() => {
|
||||
const [storeItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.sidebarItems(s),
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
|
||||
const toggleSection = (sectionKey: string) => {
|
||||
const isHidden = hiddenSections.includes(sectionKey);
|
||||
const newHidden = isHidden
|
||||
? hiddenSections.filter((k) => k !== sectionKey)
|
||||
: [...hiddenSections, sectionKey];
|
||||
updateSystemStatus({ hiddenSidebarSections: newHidden });
|
||||
};
|
||||
// Local state for drag operations — only persisted on dragEnd
|
||||
const [items, setItems] = useState<string[]>(storeItems);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// Sync local state when store changes (e.g. reset)
|
||||
useEffect(() => {
|
||||
setItems(storeItems);
|
||||
}, [storeItems]);
|
||||
|
||||
// Derive outer (with group placeholder) and inner (accordion items)
|
||||
const { innerItems, outerItems } = useMemo(() => {
|
||||
const outer: string[] = [];
|
||||
const inner: string[] = [];
|
||||
let insertedGroup = false;
|
||||
for (const id of items) {
|
||||
if (isAccordionKey(id)) {
|
||||
inner.push(id);
|
||||
if (!insertedGroup) {
|
||||
outer.push(ACCORDION_GROUP_ID);
|
||||
insertedGroup = true;
|
||||
}
|
||||
} else {
|
||||
outer.push(id);
|
||||
}
|
||||
}
|
||||
return { innerItems: inner, outerItems: outer };
|
||||
}, [items]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(key: string) => {
|
||||
const isHidden = hiddenSections.includes(key);
|
||||
const newHidden = isHidden
|
||||
? hiddenSections.filter((k) => k !== key)
|
||||
: [...hiddenSections, key];
|
||||
updateSystemStatus({ hiddenSidebarSections: newHidden });
|
||||
},
|
||||
[hiddenSections, updateSystemStatus],
|
||||
);
|
||||
|
||||
// Collision detection: restrict targets to the same container as the active item.
|
||||
// - Active in inner (recents/agent) → only collide with inner items
|
||||
// - Active in outer (pages/community/... or the group itself) → only collide with outer items
|
||||
const collisionDetection = useCallback<CollisionDetection>((args) => {
|
||||
const activeId = args.active.id as string;
|
||||
const isInner = isAccordionKey(activeId);
|
||||
const droppableContainers = args.droppableContainers.filter((c) => {
|
||||
const id = c.id as string;
|
||||
const targetIsInner = isAccordionKey(id);
|
||||
return isInner === targetIsInner;
|
||||
});
|
||||
return closestCenter({ ...args, droppableContainers });
|
||||
}, []);
|
||||
|
||||
// ---- DnD handlers ----
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeKey = active.id as string;
|
||||
const overKey = over.id as string;
|
||||
|
||||
let next: string[];
|
||||
if (isAccordionKey(activeKey)) {
|
||||
// Inner reorder (recents ↔ agent)
|
||||
const oldIdx = innerItems.indexOf(activeKey);
|
||||
const newIdx = innerItems.indexOf(overKey);
|
||||
if (oldIdx === -1 || newIdx === -1) return;
|
||||
next = flattenItems(outerItems, arrayMove(innerItems, oldIdx, newIdx));
|
||||
} else {
|
||||
// Outer reorder (pages/community/... or the whole accordion group)
|
||||
const oldIdx = outerItems.indexOf(activeKey);
|
||||
const newIdx = outerItems.indexOf(overKey);
|
||||
if (oldIdx === -1 || newIdx === -1) return;
|
||||
next = flattenItems(arrayMove(outerItems, oldIdx, newIdx), innerItems);
|
||||
}
|
||||
|
||||
setItems(next);
|
||||
updateSystemStatus({ sidebarItems: next });
|
||||
},
|
||||
[innerItems, outerItems, updateSystemStatus],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setItems(storeItems);
|
||||
}, [storeItems]);
|
||||
|
||||
const renderItem = (id: string) => (
|
||||
<SortableItem hiddenSections={hiddenSections} id={id} key={id} onToggle={toggleSection} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Flexbox gap={2}>
|
||||
{TOP_NAV_ITEMS.map((item) => {
|
||||
const route = item.routeId ? getRouteById(item.routeId) : undefined;
|
||||
const isHidden = hiddenSections.includes(item.key);
|
||||
return (
|
||||
<SectionRow
|
||||
icon={route?.icon}
|
||||
isHidden={isHidden}
|
||||
key={item.key}
|
||||
label={t(item.labelKey as any)}
|
||||
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
|
||||
onToggle={() => toggleSection(item.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{sidebarSectionOrder.map((key) => {
|
||||
const item = SECTION_ITEMS.find((i) => i.key === key);
|
||||
if (!item) return null;
|
||||
const isHidden = !item.alwaysVisible && hiddenSections.includes(key);
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection}
|
||||
sensors={sensors}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
<SortableContext items={outerItems} strategy={verticalListSortingStrategy}>
|
||||
<Flexbox gap={2}>
|
||||
{outerItems.map((id) =>
|
||||
id === ACCORDION_GROUP_ID ? (
|
||||
<AccordionGroup key={id}>
|
||||
<SortableContext items={innerItems} strategy={verticalListSortingStrategy}>
|
||||
{innerItems.map(renderItem)}
|
||||
</SortableContext>
|
||||
</AccordionGroup>
|
||||
) : (
|
||||
renderItem(id)
|
||||
),
|
||||
)}
|
||||
</Flexbox>
|
||||
</SortableContext>
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
alwaysVisible={item.alwaysVisible}
|
||||
isHidden={isHidden}
|
||||
key={key}
|
||||
label={t(item.labelKey as any)}
|
||||
pinnedTooltip={t('navPanel.pinned' as any)}
|
||||
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
|
||||
onToggle={() => toggleSection(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{BOTTOM_ITEMS.map((item) => {
|
||||
const route = item.routeId ? getRouteById(item.routeId) : undefined;
|
||||
const icon = route?.icon;
|
||||
const isHidden = hiddenSections.includes(item.key);
|
||||
return (
|
||||
<SectionRow
|
||||
icon={icon}
|
||||
isHidden={isHidden}
|
||||
key={item.key}
|
||||
label={t(item.labelKey as any)}
|
||||
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
|
||||
onToggle={() => toggleSection(item.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
|
||||
{activeId ? <OverlayItem id={activeId} /> : null}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CustomizeSidebarModal = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
const open = useCustomizeSidebarModalStore((s) => s.open);
|
||||
const setOpen = useCustomizeSidebarModalStore((s) => s.setOpen);
|
||||
const resetSidebarCustomization = useGlobalStore((s) => s.resetSidebarCustomization);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
open={open}
|
||||
title={t('navPanel.customizeSidebar')}
|
||||
width={360}
|
||||
footer={
|
||||
<Button
|
||||
block
|
||||
icon={<Icon icon={RotateCcw} />}
|
||||
type={'text'}
|
||||
onClick={resetSidebarCustomization}
|
||||
>
|
||||
{t('navPanel.resetDefault' as any)}
|
||||
</Button>
|
||||
}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<CustomizeSidebarContent />
|
||||
|
|
|
|||
|
|
@ -1,46 +1,160 @@
|
|||
'use client';
|
||||
|
||||
import { Accordion, Flexbox } from '@lobehub/ui';
|
||||
import { memo, type ReactElement, useMemo } from 'react';
|
||||
import { Accordion, ActionIcon, DropdownMenu, Flexbox, Icon, type MenuProps } from '@lobehub/ui';
|
||||
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { memo, type ReactElement, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
|
||||
import { type NavItem as NavItemType, useNavLayout } from '@/hooks/useNavLayout';
|
||||
import Recents from '@/routes/(main)/home/features/Recents';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { isModifierClick } from '@/utils/navigation';
|
||||
import { prefetchRoute } from '@/utils/router';
|
||||
|
||||
import Agent from './Agent';
|
||||
import BottomMenu from './BottomMenu';
|
||||
import { CustomizeSidebarModal } from './CustomizeSidebarModal';
|
||||
import { CustomizeSidebarModal, openCustomizeSidebarModal } from './CustomizeSidebarModal';
|
||||
|
||||
export enum GroupKey {
|
||||
Agent = 'agent',
|
||||
Community = 'community',
|
||||
Pages = 'pages',
|
||||
Project = 'project',
|
||||
Recents = 'recents',
|
||||
Resource = 'resource',
|
||||
}
|
||||
|
||||
const sectionComponents: Record<string, (key: string) => ReactElement> = {
|
||||
const ACCORDION_KEYS = new Set<string>([GroupKey.Recents, GroupKey.Agent]);
|
||||
|
||||
const accordionComponents: Record<string, (key: string) => ReactElement> = {
|
||||
[GroupKey.Agent]: (key) => <Agent itemKey={key} key={key} />,
|
||||
[GroupKey.Recents]: (key) => <Recents itemKey={key} key={key} />,
|
||||
};
|
||||
|
||||
const Body = memo(() => {
|
||||
const sidebarSectionOrder = useGlobalStore(systemStatusSelectors.sidebarSectionOrder);
|
||||
const { t } = useTranslation('common');
|
||||
const tab = useActiveTabKey();
|
||||
const navigate = useNavigate();
|
||||
const { topNavItems, bottomMenuItems } = useNavLayout();
|
||||
const sidebarItems = useGlobalStore(systemStatusSelectors.sidebarItems);
|
||||
const hiddenSections = useGlobalStore(systemStatusSelectors.hiddenSidebarSections);
|
||||
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
sidebarSectionOrder
|
||||
.filter((key) => key === GroupKey.Agent || !hiddenSections.includes(key))
|
||||
.map((key) => sectionComponents[key]?.(key))
|
||||
.filter(Boolean),
|
||||
[sidebarSectionOrder, hiddenSections],
|
||||
const hideSection = useCallback(
|
||||
(key: string) => {
|
||||
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
|
||||
},
|
||||
[hiddenSections, updateSystemStatus],
|
||||
);
|
||||
|
||||
const getContextMenuItems = useCallback(
|
||||
(key: string): MenuProps['items'] => [
|
||||
{
|
||||
icon: <Icon icon={EyeOffIcon} />,
|
||||
key: 'hideSection',
|
||||
label: t('navPanel.hideSection'),
|
||||
onClick: () => hideSection(key),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={SlidersHorizontalIcon} />,
|
||||
key: 'customizeSidebar',
|
||||
label: t('navPanel.customizeSidebar'),
|
||||
onClick: () => openCustomizeSidebarModal(),
|
||||
},
|
||||
],
|
||||
[t, hideSection],
|
||||
);
|
||||
|
||||
// Build a map of nav link items by key
|
||||
const navLinkItems = useMemo(() => {
|
||||
const map = new Map<string, NavItemType>();
|
||||
for (const item of topNavItems) map.set(item.key, item);
|
||||
for (const item of bottomMenuItems) map.set(item.key, item);
|
||||
return map;
|
||||
}, [topNavItems, bottomMenuItems]);
|
||||
|
||||
// Items that must always be visible regardless of hiddenSections
|
||||
const isVisible = useCallback(
|
||||
(k: string) => k === GroupKey.Agent || !hiddenSections.includes(k),
|
||||
[hiddenSections],
|
||||
);
|
||||
|
||||
const visibleKeys = useMemo(() => sidebarItems.filter(isVisible), [sidebarItems, isVisible]);
|
||||
|
||||
const renderNavLink = useCallback(
|
||||
(key: string) => {
|
||||
const navItem = navLinkItems.get(key);
|
||||
if (!navItem || navItem.hidden) return null;
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
to={navItem.url!}
|
||||
onMouseEnter={() => prefetchRoute(navItem.url!)}
|
||||
onClick={(e) => {
|
||||
if (isModifierClick(e)) return;
|
||||
e.preventDefault();
|
||||
navigate(navItem.url!);
|
||||
}}
|
||||
>
|
||||
<NavItem
|
||||
active={tab === key}
|
||||
contextMenuItems={getContextMenuItems(key)}
|
||||
icon={navItem.icon}
|
||||
title={navItem.title}
|
||||
actions={
|
||||
<DropdownMenu items={getContextMenuItems(key)} nativeButton={false}>
|
||||
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
[navLinkItems, tab, getContextMenuItems, navigate],
|
||||
);
|
||||
|
||||
// Render the flat list: group consecutive accordion items into an Accordion,
|
||||
// interleave non-accordion keys as nav links.
|
||||
const content = useMemo(() => {
|
||||
const elements: ReactElement[] = [];
|
||||
let accGroup: ReactElement[] = [];
|
||||
|
||||
const flushAccordion = () => {
|
||||
if (accGroup.length > 0) {
|
||||
elements.push(
|
||||
<Accordion
|
||||
defaultExpandedKeys={[GroupKey.Recents, GroupKey.Project, GroupKey.Agent]}
|
||||
gap={8}
|
||||
key={`acc-${elements.length}`}
|
||||
>
|
||||
{accGroup}
|
||||
</Accordion>,
|
||||
);
|
||||
accGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of visibleKeys) {
|
||||
if (ACCORDION_KEYS.has(key)) {
|
||||
const comp = accordionComponents[key]?.(key);
|
||||
if (comp) accGroup.push(comp);
|
||||
} else {
|
||||
flushAccordion();
|
||||
const link = renderNavLink(key);
|
||||
if (link) elements.push(link);
|
||||
}
|
||||
}
|
||||
flushAccordion();
|
||||
return elements;
|
||||
}, [visibleKeys, renderNavLink]);
|
||||
|
||||
return (
|
||||
<Flexbox flex={1} justify={'space-between'} paddingInline={4}>
|
||||
<Accordion defaultExpandedKeys={[GroupKey.Recents, GroupKey.Project, GroupKey.Agent]} gap={8}>
|
||||
{sections}
|
||||
</Accordion>
|
||||
<BottomMenu />
|
||||
<Flexbox flex={1} gap={4} paddingInline={4}>
|
||||
{content}
|
||||
<CustomizeSidebarModal />
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { type MenuProps } from '@lobehub/ui';
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag } from '@lobehub/ui';
|
||||
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Flexbox, Tag } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
@ -11,50 +9,17 @@ import { type NavItemProps } from '@/features/NavPanel/components/NavItem';
|
|||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
|
||||
import { useNavLayout } from '@/hooks/useNavLayout';
|
||||
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { isModifierClick } from '@/utils/navigation';
|
||||
import { prefetchRoute } from '@/utils/router';
|
||||
|
||||
/** Keys that cannot be hidden and should not show section actions */
|
||||
const PERMANENT_KEYS = new Set(['home', 'search']);
|
||||
/** Keys that are rendered in the header; all others are managed by Body via sidebarSectionOrder */
|
||||
const HEADER_KEYS = new Set(['home', 'search']);
|
||||
|
||||
const Nav = memo(() => {
|
||||
const tab = useActiveTabKey();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('common');
|
||||
const { topNavItems: items } = useNavLayout();
|
||||
const [hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
|
||||
const hideSection = useCallback(
|
||||
(key: string) => {
|
||||
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
|
||||
},
|
||||
[hiddenSections, updateSystemStatus],
|
||||
);
|
||||
|
||||
const getSectionMenuItems = useCallback(
|
||||
(key: string): MenuProps['items'] => [
|
||||
{
|
||||
icon: <Icon icon={EyeOffIcon} />,
|
||||
key: 'hideSection',
|
||||
label: t('navPanel.hideSection'),
|
||||
onClick: () => hideSection(key),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={SlidersHorizontalIcon} />,
|
||||
key: 'customizeSidebar',
|
||||
label: t('navPanel.customizeSidebar'),
|
||||
onClick: () => openCustomizeSidebarModal(),
|
||||
},
|
||||
],
|
||||
[t, hideSection],
|
||||
);
|
||||
|
||||
const newBadge = (
|
||||
<Tag color="blue" size="small">
|
||||
|
|
@ -65,27 +30,17 @@ const Nav = memo(() => {
|
|||
return (
|
||||
<Flexbox gap={1} paddingInline={4}>
|
||||
{items
|
||||
.filter((item) => !hiddenSections.includes(item.key))
|
||||
.filter((item) => HEADER_KEYS.has(item.key))
|
||||
.map((item) => {
|
||||
const extra = item.isNew ? newBadge : undefined;
|
||||
const canHide = !PERMANENT_KEYS.has(item.key);
|
||||
const menuItems = canHide ? getSectionMenuItems(item.key) : undefined;
|
||||
|
||||
const navItem = (
|
||||
<NavItem
|
||||
active={tab === item.key}
|
||||
contextMenuItems={menuItems}
|
||||
extra={extra}
|
||||
hidden={item.hidden}
|
||||
icon={item.icon as NavItemProps['icon']}
|
||||
title={item.title}
|
||||
actions={
|
||||
menuItems ? (
|
||||
<DropdownMenu items={menuItems} nativeButton={false}>
|
||||
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
|
||||
</DropdownMenu>
|
||||
) : undefined
|
||||
}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useInitRecents } from '@/hooks/useInitRecents';
|
|||
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { reorderSidebarItems } from '@/store/global/selectors/systemStatus';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
import { homeRecentSelectors } from '@/store/home/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
|
@ -44,30 +45,27 @@ const Recents = memo<RecentsProps>(({ itemKey }) => {
|
|||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
const { isRevalidating } = useInitRecents();
|
||||
|
||||
const [recentPageSize, sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore(
|
||||
(s) => [
|
||||
systemStatusSelectors.recentPageSize(s),
|
||||
systemStatusSelectors.sidebarSectionOrder(s),
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
],
|
||||
);
|
||||
const [recentPageSize, sidebarItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.recentPageSize(s),
|
||||
systemStatusSelectors.sidebarItems(s),
|
||||
systemStatusSelectors.hiddenSidebarSections(s),
|
||||
s.updateSystemStatus,
|
||||
]);
|
||||
|
||||
const visibleOrder = sidebarSectionOrder.filter((k) => !hiddenSections.includes(k));
|
||||
const visibleIndex = visibleOrder.indexOf('recents');
|
||||
const visibleItems = sidebarItems.filter((k) => !hiddenSections.includes(k));
|
||||
const visibleIndex = visibleItems.indexOf('recents');
|
||||
const isFirst = visibleIndex === 0;
|
||||
const isLast = visibleIndex === visibleOrder.length - 1;
|
||||
const isLast = visibleIndex === visibleItems.length - 1;
|
||||
|
||||
const moveSection = useCallback(
|
||||
(direction: 'up' | 'down') => {
|
||||
const newOrder = [...sidebarSectionOrder];
|
||||
const idx = newOrder.indexOf('recents');
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= newOrder.length) return;
|
||||
[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]];
|
||||
updateSystemStatus({ sidebarSectionOrder: newOrder });
|
||||
const idx = sidebarItems.indexOf('recents');
|
||||
if (idx === -1) return;
|
||||
const next = reorderSidebarItems(sidebarItems, idx, direction === 'up' ? idx - 1 : idx + 1);
|
||||
if (next === sidebarItems) return;
|
||||
updateSystemStatus({ sidebarItems: next });
|
||||
},
|
||||
[sidebarSectionOrder, updateSystemStatus],
|
||||
[sidebarItems, updateSystemStatus],
|
||||
);
|
||||
|
||||
const hideSection = useCallback(() => {
|
||||
|
|
@ -130,7 +128,7 @@ const Recents = memo<RecentsProps>(({ itemKey }) => {
|
|||
isLast,
|
||||
moveSection,
|
||||
hideSection,
|
||||
visibleOrder.length,
|
||||
visibleItems.length,
|
||||
]);
|
||||
|
||||
if (!isLogin) return null;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { switchLang } from '@/utils/client/switchLang';
|
|||
import { merge } from '@/utils/merge';
|
||||
import { setNamespace } from '@/utils/storeDebug';
|
||||
|
||||
import { DEFAULT_HIDDEN_SECTIONS, DEFAULT_SIDEBAR_ITEMS } from '../selectors/systemStatus';
|
||||
import { type GlobalStore } from '../store';
|
||||
|
||||
const n = setNamespace('g');
|
||||
|
|
@ -130,6 +131,13 @@ export class GlobalGeneralActionImpl {
|
|||
});
|
||||
};
|
||||
|
||||
resetSidebarCustomization = (): void => {
|
||||
this.#get().updateSystemStatus(
|
||||
{ hiddenSidebarSections: DEFAULT_HIDDEN_SECTIONS, sidebarItems: DEFAULT_SIDEBAR_ITEMS },
|
||||
n('resetSidebarCustomization'),
|
||||
);
|
||||
};
|
||||
|
||||
updateSystemStatus = (status: Partial<SystemStatus>, action?: any): void => {
|
||||
if (!this.#get().isStatusInit) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,12 @@ export interface SystemStatus {
|
|||
showVideoPanel?: boolean;
|
||||
showVideoTopicPanel?: boolean;
|
||||
/**
|
||||
* Order of sidebar sections (e.g. ['recents', 'agent'])
|
||||
* Flat ordered list of sidebar items.
|
||||
*/
|
||||
sidebarItems?: string[];
|
||||
/**
|
||||
* Legacy accordion-only ordering (recents/agent) from the pre-rework sidebar.
|
||||
* @deprecated Kept for one-time migration into `sidebarItems`.
|
||||
*/
|
||||
sidebarSectionOrder?: string[];
|
||||
systemRoleExpandedMap: Record<string, boolean>;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { merge } from '@/utils/merge';
|
|||
|
||||
import { type GlobalState } from '../initialState';
|
||||
import { INITIAL_STATUS, initialState } from '../initialState';
|
||||
import { systemStatusSelectors } from './systemStatus';
|
||||
import { DEFAULT_SIDEBAR_ITEMS, reorderSidebarItems, systemStatusSelectors } from './systemStatus';
|
||||
|
||||
// Mock version constants
|
||||
vi.mock('@/const/version', () => ({
|
||||
|
|
@ -88,4 +88,142 @@ describe('systemStatusSelectors', () => {
|
|||
expect(systemStatusSelectors.portalWidth(noPortalWidth)).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebarItems', () => {
|
||||
it('should return DEFAULT_SIDEBAR_ITEMS when no data is set', () => {
|
||||
expect(systemStatusSelectors.sidebarItems(initialState)).toEqual(DEFAULT_SIDEBAR_ITEMS);
|
||||
});
|
||||
|
||||
it('should return stored items when set', () => {
|
||||
const custom = ['agent', 'recents', 'pages', 'community', 'resource', 'memory'];
|
||||
const s: GlobalState = merge(initialState, {
|
||||
status: { sidebarItems: custom },
|
||||
});
|
||||
expect(systemStatusSelectors.sidebarItems(s)).toEqual(custom);
|
||||
});
|
||||
|
||||
it('should append missing known keys to the end', () => {
|
||||
const s: GlobalState = merge(initialState, {
|
||||
status: { sidebarItems: ['agent', 'recents'] },
|
||||
});
|
||||
const items = systemStatusSelectors.sidebarItems(s);
|
||||
// stored order preserved at the front
|
||||
expect(items.slice(0, 2)).toEqual(['agent', 'recents']);
|
||||
// every known key is present
|
||||
expect(items).toContain('pages');
|
||||
expect(items).toContain('community');
|
||||
expect(items).toContain('resource');
|
||||
expect(items).toContain('memory');
|
||||
});
|
||||
|
||||
it('should migrate legacy `sidebarSectionOrder` accordion order into the default layout', () => {
|
||||
const s: GlobalState = merge(initialState, {
|
||||
status: { sidebarSectionOrder: ['agent', 'recents'] },
|
||||
});
|
||||
const items = systemStatusSelectors.sidebarItems(s);
|
||||
// accordion slot in the default list now uses the user's legacy order
|
||||
expect(items).toEqual(['pages', 'agent', 'recents', 'community', 'resource', 'memory']);
|
||||
});
|
||||
|
||||
it('should fall back to default when legacy `sidebarSectionOrder` is the default order', () => {
|
||||
const s: GlobalState = merge(initialState, {
|
||||
status: { sidebarSectionOrder: ['recents', 'agent'] },
|
||||
});
|
||||
const items = systemStatusSelectors.sidebarItems(s);
|
||||
expect(items).toEqual(DEFAULT_SIDEBAR_ITEMS);
|
||||
});
|
||||
|
||||
it('should prefer `sidebarItems` over legacy `sidebarSectionOrder` when both are set', () => {
|
||||
const s: GlobalState = merge(initialState, {
|
||||
status: {
|
||||
sidebarItems: ['pages', 'recents', 'agent', 'community', 'resource', 'memory'],
|
||||
sidebarSectionOrder: ['agent', 'recents'],
|
||||
},
|
||||
});
|
||||
const items = systemStatusSelectors.sidebarItems(s);
|
||||
expect(items.indexOf('recents')).toBeLessThan(items.indexOf('agent'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderSidebarItems', () => {
|
||||
const DEFAULT = ['pages', 'recents', 'agent', 'community', 'resource', 'memory'];
|
||||
|
||||
it('should move a non-accordion item normally', () => {
|
||||
// move `community` (idx 3) up to idx 0
|
||||
expect(reorderSidebarItems(DEFAULT, 3, 0)).toEqual([
|
||||
'community',
|
||||
'pages',
|
||||
'recents',
|
||||
'agent',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should snap a non-accordion item out when dragged between accordion items (drag down)', () => {
|
||||
// `pages` (idx 0) dragged down to idx 2 (between recents & agent)
|
||||
// after drag-down: push past the accordion block
|
||||
expect(reorderSidebarItems(DEFAULT, 0, 2)).toEqual([
|
||||
'recents',
|
||||
'agent',
|
||||
'pages',
|
||||
'community',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should snap a non-accordion item out when dragged between accordion items (drag up)', () => {
|
||||
// `community` (idx 3) dragged up to idx 2 (between recents & agent)
|
||||
// after drag-up: place before the accordion block
|
||||
expect(reorderSidebarItems(DEFAULT, 3, 2)).toEqual([
|
||||
'pages',
|
||||
'community',
|
||||
'recents',
|
||||
'agent',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should move the whole accordion block when moving `recents` up past the block boundary', () => {
|
||||
// `recents` (idx 1) moveUp → idx 0. Block [recents, agent] slides to top.
|
||||
expect(reorderSidebarItems(DEFAULT, 1, 0)).toEqual([
|
||||
'recents',
|
||||
'agent',
|
||||
'pages',
|
||||
'community',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should move the whole accordion block when moving `agent` down past the block boundary', () => {
|
||||
// `agent` (idx 2) moveDown → idx 3. Block slides past community.
|
||||
expect(reorderSidebarItems(DEFAULT, 2, 3)).toEqual([
|
||||
'pages',
|
||||
'community',
|
||||
'recents',
|
||||
'agent',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should swap recents and agent within the block', () => {
|
||||
// `recents` (idx 1) moveDown → idx 2. Within block, so just swap.
|
||||
expect(reorderSidebarItems(DEFAULT, 1, 2)).toEqual([
|
||||
'pages',
|
||||
'agent',
|
||||
'recents',
|
||||
'community',
|
||||
'resource',
|
||||
'memory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be a no-op when from === to', () => {
|
||||
expect(reorderSidebarItems(DEFAULT, 2, 2)).toBe(DEFAULT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,9 +18,128 @@ const recentPageSize = (s: GlobalState): number => s.status.recentPageSize || 5;
|
|||
|
||||
const pagePageSize = (s: GlobalState): number => s.status.pagePageSize || 20;
|
||||
|
||||
const hiddenSidebarSections = (s: GlobalState): string[] => s.status.hiddenSidebarSections || [];
|
||||
const sidebarSectionOrder = (s: GlobalState): string[] =>
|
||||
s.status.sidebarSectionOrder || ['recents', 'agent'];
|
||||
export const DEFAULT_HIDDEN_SECTIONS: string[] = ['memory'];
|
||||
|
||||
const hiddenSidebarSections = (s: GlobalState): string[] =>
|
||||
s.status.hiddenSidebarSections ?? DEFAULT_HIDDEN_SECTIONS;
|
||||
|
||||
export const DEFAULT_SIDEBAR_ITEMS: string[] = [
|
||||
'pages',
|
||||
'recents',
|
||||
'agent',
|
||||
'community',
|
||||
'resource',
|
||||
'memory',
|
||||
];
|
||||
|
||||
/** Items that must stay contiguous in the sidebar list (accordion block). */
|
||||
export const SIDEBAR_ACCORDION_KEYS = new Set(['recents', 'agent']);
|
||||
|
||||
/** Append any known keys missing from `order` so new items don't disappear on upgrade. */
|
||||
const withAllKnownKeys = (order: string[]): string[] => {
|
||||
const present = new Set(order);
|
||||
const missing = DEFAULT_SIDEBAR_ITEMS.filter((k) => !present.has(k));
|
||||
return missing.length === 0 ? order : [...order, ...missing];
|
||||
};
|
||||
|
||||
const accordionIndices = (items: string[]): number[] => {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (SIDEBAR_ACCORDION_KEYS.has(items[i])) out.push(i);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder sidebar items while keeping the accordion block (recents + agent) contiguous.
|
||||
* - If moving an accordion item across the block boundary, moves the whole block.
|
||||
* - If moving a non-accordion item into the middle of the block, snaps it to the side
|
||||
* matching the drag direction.
|
||||
*/
|
||||
export const reorderSidebarItems = (items: string[], from: number, to: number): string[] => {
|
||||
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const key = items[from];
|
||||
const accIdx = accordionIndices(items);
|
||||
|
||||
// Moving an accordion item across the block's outer boundary → move whole block together.
|
||||
if (SIDEBAR_ACCORDION_KEYS.has(key) && accIdx.length >= 2) {
|
||||
const first = accIdx[0];
|
||||
const last = accIdx.at(-1)!;
|
||||
const crossesBoundary = (from === first && to < first) || (from === last && to > last);
|
||||
if (crossesBoundary) {
|
||||
const block = items.slice(first, last + 1);
|
||||
const without = [...items.slice(0, first), ...items.slice(last + 1)];
|
||||
// After removing the block, adjust target index for upward/downward movement
|
||||
const targetIdx = to < first ? to : to - (last - first + 1) + 1;
|
||||
const clamped = Math.max(0, Math.min(without.length, targetIdx));
|
||||
return [...without.slice(0, clamped), ...block, ...without.slice(clamped)];
|
||||
}
|
||||
}
|
||||
|
||||
// Standard reorder
|
||||
const moved = [...items];
|
||||
const [removed] = moved.splice(from, 1);
|
||||
moved.splice(to, 0, removed);
|
||||
|
||||
// Non-accordion item that landed between accordion items → snap to the side matching drag direction.
|
||||
if (!SIDEBAR_ACCORDION_KEYS.has(key)) {
|
||||
const nextAcc = accordionIndices(moved);
|
||||
if (nextAcc.length >= 2) {
|
||||
const first = nextAcc[0];
|
||||
const last = nextAcc.at(-1)!;
|
||||
const contiguous = last - first === nextAcc.length - 1;
|
||||
if (!contiguous) {
|
||||
const pos = moved.indexOf(key);
|
||||
if (pos > first && pos < last) {
|
||||
const cleaned = [...moved];
|
||||
cleaned.splice(pos, 1);
|
||||
const cAcc = accordionIndices(cleaned);
|
||||
const insertAt = from < to ? cAcc.at(-1)! + 1 : cAcc[0];
|
||||
cleaned.splice(insertAt, 0, key);
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moved;
|
||||
};
|
||||
|
||||
const sidebarItems = (s: GlobalState): string[] => {
|
||||
const items = s.status.sidebarItems;
|
||||
if (items && items.length > 0) return withAllKnownKeys(items);
|
||||
|
||||
// Migrate from the legacy `sidebarSectionOrder` (canary) which only stored the
|
||||
// accordion order (e.g. ['agent', 'recents']). Apply that order to the accordion
|
||||
// slot inside the default list so users keep their custom accordion arrangement.
|
||||
const legacy = s.status.sidebarSectionOrder;
|
||||
if (legacy && legacy.length > 0) {
|
||||
const legacyAcc = legacy.filter((k) => SIDEBAR_ACCORDION_KEYS.has(k));
|
||||
if (legacyAcc.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const k of DEFAULT_SIDEBAR_ITEMS) {
|
||||
if (SIDEBAR_ACCORDION_KEYS.has(k)) {
|
||||
for (const lk of legacyAcc) {
|
||||
if (!seen.has(lk)) {
|
||||
merged.push(lk);
|
||||
seen.add(lk);
|
||||
}
|
||||
}
|
||||
} else if (!seen.has(k)) {
|
||||
merged.push(k);
|
||||
seen.add(k);
|
||||
}
|
||||
}
|
||||
return withAllKnownKeys(merged);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_SIDEBAR_ITEMS;
|
||||
};
|
||||
const showSystemRole = (s: GlobalState) => s.status.showSystemRole;
|
||||
const mobileShowTopic = (s: GlobalState) => s.status.mobileShowTopic;
|
||||
const mobileShowPortal = (s: GlobalState) => s.status.mobileShowPortal;
|
||||
|
|
@ -116,7 +235,7 @@ export const systemStatusSelectors = {
|
|||
pagePageSize,
|
||||
portalWidth,
|
||||
recentPageSize,
|
||||
sidebarSectionOrder,
|
||||
sidebarItems,
|
||||
sessionGroupKeys,
|
||||
showChatHeader,
|
||||
showFilePanel,
|
||||
|
|
|
|||
Loading…
Reference in a new issue