♻️ refactor: centralize NavBar dev mode logic into useNavLayout hook (#13037)

* ♻️ refactor: centralize NavBar dev mode logic into useNavLayout hook

Extract scattered isDevMode checks from Nav, BottomMenu, Footer, and
UserPanel into a single useNavLayout hook with declarative devOnly
metadata. Also restore dev-mode-gated home page modules and fix
LangButton visual alignment in UserPanel.

*  test: update PanelContent test to match LangButton Menu removal
This commit is contained in:
Innei
2026-03-16 23:16:13 +08:00
committed by GitHub
parent fc5b462892
commit 23385abaea
8 changed files with 291 additions and 131 deletions

View File

@@ -1,10 +1,10 @@
import { type DropdownMenuCheckboxItem, type DropdownMenuProps } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { ChevronRight, Languages } from 'lucide-react';
import { memo, type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Menu from '@/components/Menu';
import { localeOptions } from '@/locales/resources';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
@@ -68,18 +68,30 @@ const LangButton = memo<{ placement?: DropdownMenuProps['placement']; size?: num
trigger = <ActionIcon icon={Languages} size={size} />;
} else {
trigger = (
<div>
<Menu
items={[
{
extra: <Icon icon={ChevronRight} size={'small'} />,
icon: <Icon icon={Languages} />,
key: 'language',
label: t('settingCommon.lang.title'),
},
]}
/>
</div>
<Flexbox
horizontal
align="center"
gap={12}
style={{
borderRadius: 8,
boxSizing: 'content-box',
cursor: 'pointer',
height: 28,
marginInline: 4,
paddingBlock: 6,
paddingInline: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = cssVar.colorFillTertiary as string;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Icon icon={Languages} size={'small'} style={{ color: cssVar.colorTextSecondary }} />
<Flexbox flex={1}>{t('settingCommon.lang.title')}</Flexbox>
<Icon icon={ChevronRight} size={'small'} style={{ color: cssVar.colorTextSecondary }} />
</Flexbox>
);
}
@@ -87,11 +99,13 @@ const LangButton = memo<{ placement?: DropdownMenuProps['placement']; size?: num
<DropdownMenu
items={items}
placement={placement}
trigger="hover"
popupProps={{
style: {
maxHeight: 360,
minWidth: 240,
overflow: 'auto',
transition: 'none',
},
}}
>

View File

@@ -2,7 +2,7 @@ import { LOBE_CHAT_CLOUD, UTM_SOURCE } from '@lobechat/business-const';
import { DOWNLOAD_URL, isDesktop } from '@lobechat/const';
import { Flexbox, Hotkey, Icon, Tag } from '@lobehub/ui';
import { type ItemType } from 'antd/es/menu/interface';
import { BrainCircuit, Cloudy, Download, LogOut, Settings2 } from 'lucide-react';
import { BrainCircuit, Cloudy, Download, HardDriveDownload, LogOut, Settings2 } from 'lucide-react';
import { type PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,6 +12,8 @@ import getBusinessMenuItems from '@/business/client/features/User/getBusinessMen
import { type MenuProps } from '@/components/Menu';
import { DEFAULT_DESKTOP_HOTKEY_CONFIG } from '@/const/desktop';
import { OFFICIAL_URL } from '@/const/url';
import DataImporter from '@/features/DataImporter';
import { useNavLayout } from '@/hooks/useNavLayout';
import { usePlatform } from '@/hooks/usePlatform';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
@@ -51,6 +53,7 @@ export const useMenu = () => {
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
]);
const { userPanel } = useNavLayout();
const businessMenuItems = getBusinessMenuItems(isLogin);
const { isIOS, isAndroid } = usePlatform();
@@ -75,11 +78,15 @@ export const useMenu = () => {
</Link>
),
},
{
icon: <Icon icon={BrainCircuit} />,
key: 'memory',
label: <Link to="/memory">{t('tab.memory')}</Link>,
},
...(userPanel.showMemory
? [
{
icon: <Icon icon={BrainCircuit} />,
key: 'memory',
label: <Link to="/memory">{t('tab.memory')}</Link>,
},
]
: []),
];
const getDesktopApp: MenuProps['items'] = [
@@ -118,6 +125,18 @@ export const useMenu = () => {
...(isLogin ? settings : []),
...businessMenuItems,
...(!isDesktop ? [{ type: 'divider' as const }, ...getDesktopApp] : []),
...(userPanel.showDataImporter && isLogin
? [
{
icon: <Icon icon={HardDriveDownload} />,
key: 'import',
label: <DataImporter>{t('importData')}</DataImporter>,
},
{
type: 'divider' as const,
},
]
: []),
...(!hideDocs ? helps : []),
].filter(Boolean) as MenuProps['items'];

View File

@@ -103,7 +103,7 @@ describe('PanelContent', () => {
renderWithRouter(<PanelContent closePopover={closePopover} />);
expect(screen.getAllByText('Mocked Menu').length).toBe(3);
expect(screen.getAllByText('Mocked Menu').length).toBe(2);
});
it('should render SignInBlock when user is not signed in', () => {

163
src/hooks/useNavLayout.ts Normal file
View File

@@ -0,0 +1,163 @@
import { HomeIcon, SearchIcon } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getRouteById } from '@/config/routes';
import { useGlobalStore } from '@/store/global';
import { SidebarTabKey } from '@/store/global/initialState';
import {
featureFlagsSelectors,
serverConfigSelectors,
useServerConfigStore,
} from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
export interface NavItem {
/**
* true = dev mode only, false = simplified mode only, undefined = always
*/
devOnly?: boolean;
hidden?: boolean;
icon: any;
isNew?: boolean;
key: string;
onClick?: () => void;
title: string;
url?: string;
}
export interface NavLayout {
bottomMenuItems: NavItem[];
footer: {
hideGitHub: boolean;
layout: 'expanded' | 'compact';
showEvalEntry: boolean;
showSettingsEntry: boolean;
};
topNavItems: NavItem[];
userPanel: {
showDataImporter: boolean;
showMemory: boolean;
};
}
const filterByMode = (items: NavItem[], isDevMode: boolean): NavItem[] =>
items.filter((item) => item.devOnly === undefined || item.devOnly === isDevMode);
export const useNavLayout = (): NavLayout => {
const { t } = useTranslation('common');
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
const { showMarket, showAiImage, hideGitHub } = useServerConfigStore(featureFlagsSelectors);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const topNavItems = useMemo(
() =>
filterByMode(
[
{
icon: SearchIcon,
key: 'search',
onClick: () => toggleCommandMenu(true),
title: t('tab.search'),
},
{
icon: HomeIcon,
key: SidebarTabKey.Home,
title: t('tab.home'),
url: '/',
},
{
devOnly: true,
icon: getRouteById('page')!.icon,
key: SidebarTabKey.Pages,
title: t('tab.pages'),
url: '/page',
},
{
devOnly: true,
hidden: !enableBusinessFeatures,
icon: getRouteById('video')!.icon,
key: SidebarTabKey.Video,
title: t('tab.video'),
url: '/video',
},
{
devOnly: true,
hidden: !showAiImage,
icon: getRouteById('image')!.icon,
key: SidebarTabKey.Image,
title: t('tab.aiImage'),
url: '/image',
},
{
hidden: !showMarket,
icon: getRouteById('community')!.icon,
key: SidebarTabKey.Community,
title: t('tab.marketplace'),
url: '/community',
},
],
isDevMode,
),
[t, toggleCommandMenu, showMarket, isDevMode, enableBusinessFeatures, showAiImage],
);
const bottomMenuItems = useMemo(
() =>
filterByMode(
[
{
devOnly: true,
icon: getRouteById('settings')!.icon,
key: SidebarTabKey.Setting,
title: t('tab.setting'),
url: '/settings',
},
{
icon: getRouteById('resource')!.icon,
key: SidebarTabKey.Resource,
title: t('tab.resource'),
url: '/resource',
},
{
devOnly: true,
icon: getRouteById('memory')!.icon,
key: SidebarTabKey.Memory,
title: t('tab.memory'),
url: '/memory',
},
{
devOnly: false,
icon: getRouteById('page')!.icon,
key: SidebarTabKey.Pages,
title: t('tab.pages'),
url: '/page',
},
],
isDevMode,
),
[t, isDevMode],
);
const footer = useMemo(
() => ({
hideGitHub: !!hideGitHub,
layout: (isDevMode ? 'expanded' : 'compact') as 'expanded' | 'compact',
showEvalEntry: isDevMode,
showSettingsEntry: !isDevMode,
}),
[isDevMode, hideGitHub],
);
const userPanel = useMemo(
() => ({
showDataImporter: isDevMode,
showMemory: !isDevMode,
}),
[isDevMode],
);
return { bottomMenuItems, footer, topNavItems, userPanel };
};

View File

@@ -1,45 +1,16 @@
import { Flexbox } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { getRouteById } from '@/config/routes';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { SidebarTabKey } from '@/store/global/initialState';
import { useNavLayout } from '@/hooks/useNavLayout';
import { isModifierClick } from '@/utils/navigation';
interface Item {
icon: any;
key: SidebarTabKey;
title: string;
url: string;
}
const BottomMenu = memo(() => {
const tab = useActiveTabKey();
const navigate = useNavigate();
const { t } = useTranslation('common');
const items = useMemo(
() =>
[
{
icon: getRouteById('resource')!.icon,
key: SidebarTabKey.Resource,
title: t('tab.resource'),
url: '/resource',
},
{
icon: getRouteById('page')!.icon,
key: SidebarTabKey.Pages,
title: t('tab.pages'),
url: '/page',
},
].filter(Boolean) as Item[],
[t],
);
const { bottomMenuItems: items } = useNavLayout();
return (
<Flexbox
@@ -52,11 +23,11 @@ const BottomMenu = memo(() => {
{items.map((item) => (
<Link
key={item.key}
to={item.url}
to={item.url!}
onClick={(e) => {
if (isModifierClick(e)) return;
e.preventDefault();
navigate(item.url);
navigate(item.url!);
}}
>
<NavItem active={tab === item.key} icon={item.icon} title={item.title} />

View File

@@ -22,12 +22,11 @@ import { Link } from 'react-router-dom';
import ChangelogModal from '@/components/ChangelogModal';
import HighlightNotification from '@/components/HighlightNotification';
import { DOCUMENTS_REFER_URL, GITHUB } from '@/const/url';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useFeedbackModal } from '@/hooks/useFeedbackModal';
import { useNavLayout } from '@/hooks/useNavLayout';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors/systemStatus';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
const PRODUCT_HUNT_NOTIFICATION = {
actionHref: 'https://www.producthunt.com/products/lobehub?launch=lobehub',
@@ -40,8 +39,7 @@ const PRODUCT_HUNT_NOTIFICATION = {
const Footer = memo(() => {
const { t } = useTranslation('common');
const { analytics } = useAnalytics();
const { hideGitHub } = useServerConfigStore(featureFlagsSelectors);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const { footer } = useNavLayout();
const [shouldLoadChangelog, setShouldLoadChangelog] = useState(false);
const [isChangelogModalOpen, setIsChangelogModalOpen] = useState(false);
const [isProductHuntCardOpen, setIsProductHuntCardOpen] = useState(false);
@@ -88,17 +86,17 @@ const Footer = memo(() => {
setIsChangelogModalOpen(false);
};
const handleOpenFeedbackModal = () => {
const handleOpenFeedbackModal = useCallback(() => {
openFeedbackModal();
};
}, [openFeedbackModal]);
const handleOpenProductHuntCard = () => {
const handleOpenProductHuntCard = useCallback(() => {
setIsProductHuntCardOpen(true);
trackProductHuntEvent('product_hunt_card_viewed', {
spm: 'homepage.product_hunt.viewed',
trigger: 'menu_click',
});
};
}, [setIsProductHuntCardOpen, trackProductHuntEvent]);
const handleCloseProductHuntCard = () => {
setIsProductHuntCardOpen(false);
@@ -121,14 +119,18 @@ const Footer = memo(() => {
const helpMenuItems: MenuProps['items'] = useMemo(
() => [
{
icon: <Icon icon={Settings2} />,
key: 'setting',
label: <Link to="/settings">{t('userPanel.setting')}</Link>,
},
{
type: 'divider' as const,
},
...(footer.showSettingsEntry
? [
{
icon: <Icon icon={Settings2} />,
key: 'setting',
label: <Link to="/settings">{t('userPanel.setting')}</Link>,
},
{
type: 'divider' as const,
},
]
: []),
{
icon: <Icon icon={Book} />,
key: 'docs',
@@ -162,7 +164,7 @@ const Footer = memo(() => {
label: t('changelog'),
onClick: handleOpenChangelogModal,
},
...(!hideGitHub
...(footer.layout === 'compact' && !footer.hideGitHub
? [
{
icon: <Icon icon={Github} />,
@@ -175,7 +177,7 @@ const Footer = memo(() => {
},
]
: []),
...(isDevMode
...(footer.showEvalEntry && footer.layout === 'compact'
? [
{
icon: <Icon icon={FlaskConical} />,
@@ -195,16 +197,44 @@ const Footer = memo(() => {
]
: []),
],
[t, isWithinTimeWindow, hideGitHub, isDevMode],
[
footer.showSettingsEntry,
footer.layout,
footer.hideGitHub,
footer.showEvalEntry,
t,
handleOpenFeedbackModal,
isWithinTimeWindow,
handleOpenProductHuntCard,
],
);
return (
<>
<Flexbox horizontal align={'center'} gap={2} padding={8}>
<DropdownMenu items={helpMenuItems} placement="topLeft">
<ActionIcon aria-label={t('userPanel.help')} icon={CircleHelp} size={16} />
</DropdownMenu>
</Flexbox>
{footer.layout === 'expanded' ? (
<Flexbox horizontal align={'center'} gap={2} justify={'space-between'} padding={8}>
<Flexbox horizontal align={'center'} flex={1} gap={2}>
<DropdownMenu items={helpMenuItems} placement="topLeft">
<ActionIcon aria-label={t('userPanel.help')} icon={CircleHelp} size={16} />
</DropdownMenu>
{!footer.hideGitHub && (
<a aria-label={'GitHub'} href={GITHUB} rel="noopener noreferrer" target={'_blank'}>
<ActionIcon icon={Github} size={16} title={'GitHub'} />
</a>
)}
<Link to="/eval">
<ActionIcon icon={FlaskConical} size={16} title="Evaluation Lab" />
</Link>
</Flexbox>
<ThemeButton placement={'topCenter'} size={16} />
</Flexbox>
) : (
<Flexbox horizontal align={'center'} gap={2} padding={8}>
<DropdownMenu items={helpMenuItems} placement="topLeft">
<ActionIcon aria-label={t('userPanel.help')} icon={CircleHelp} size={16} />
</DropdownMenu>
</Flexbox>
)}
<ChangelogModal
open={isChangelogModalOpen}
shouldLoad={shouldLoadChangelog}

View File

@@ -1,63 +1,21 @@
'use client';
import { Flexbox, Tag } from '@lobehub/ui';
import { HomeIcon, SearchIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { getRouteById } from '@/config/routes';
import { type NavItemProps } from '@/features/NavPanel/components/NavItem';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { useGlobalStore } from '@/store/global';
import { SidebarTabKey } from '@/store/global/initialState';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useNavLayout } from '@/hooks/useNavLayout';
import { isModifierClick } from '@/utils/navigation';
interface Item {
hidden?: boolean | undefined;
icon: NavItemProps['icon'];
isNew?: boolean;
key: string;
onClick?: () => void;
title: NavItemProps['title'];
url?: string;
}
const Nav = memo(() => {
const tab = useActiveTabKey();
const navigate = useNavigate();
const { t } = useTranslation('common');
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
const { showMarket } = useServerConfigStore(featureFlagsSelectors);
const items: Item[] = useMemo(
() => [
{
icon: SearchIcon,
key: 'search',
onClick: () => {
toggleCommandMenu(true);
},
title: t('tab.search'),
},
{
icon: HomeIcon,
key: SidebarTabKey.Home,
title: t('tab.home'),
url: '/',
},
{
hidden: !showMarket,
icon: getRouteById('community')!.icon,
key: SidebarTabKey.Community,
title: t('tab.marketplace'),
url: '/community',
},
],
[t, showMarket],
);
const { topNavItems: items } = useNavLayout();
const newBadge = (
<Tag color="blue" size="small">
@@ -74,7 +32,7 @@ const Nav = memo(() => {
active={tab === item.key}
extra={extra}
hidden={item.hidden}
icon={item.icon}
icon={item.icon as NavItemProps['icon']}
key={item.key}
title={item.title}
onClick={item.onClick}
@@ -99,7 +57,7 @@ const Nav = memo(() => {
active={tab === item.key}
extra={extra}
hidden={item.hidden}
icon={item.icon}
icon={item.icon as NavItemProps['icon']}
title={item.title}
/>
</Link>

View File

@@ -7,13 +7,19 @@ import { useTranslation } from 'react-i18next';
import { useHomeStore } from '@/store/home';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
import CommunityAgents from './CommunityAgents';
import InputArea from './InputArea';
import RecentPage from './RecentPage';
import RecentResource from './RecentResource';
import RecentTopic from './RecentTopic';
import WelcomeText from './WelcomeText';
const Home = memo(() => {
const { i18n } = useTranslation();
const isLogin = useUserStore(authSelectors.isLogin);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const inputActiveMode = useHomeStore((s) => s.inputActiveMode);
// Hide other modules when a starter mode is active
@@ -28,15 +34,14 @@ const Home = memo(() => {
<InputArea />
{/* Use CSS visibility to hide instead of unmounting to prevent data re-fetching */}
<Flexbox gap={40} style={{ display: hideOtherModules ? 'none' : undefined }}>
{/* {isLogin && (
{isDevMode && isLogin && (
<>
<RecentTopic />
<RecentPage />
</>
)} */}
{/* <CommunityAgents /> */}
{/* <FeaturedPlugins /> */}
{/* {isLogin && <RecentResource />} */}
)}
{isDevMode && <CommunityAgents />}
{isDevMode && isLogin && <RecentResource />}
</Flexbox>
</Flexbox>
);