import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ComponentType } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Drawer, Layout, Menu } from 'antd'; import type { MenuProps } from 'antd'; import { ApiOutlined, CloseOutlined, CloudServerOutlined, ClusterOutlined, CodeOutlined, DashboardOutlined, DatabaseOutlined, GithubOutlined, HeartOutlined, ImportOutlined, LogoutOutlined, MailOutlined, MenuOutlined, MessageOutlined, MoonFilled, MoonOutlined, SafetyOutlined, SettingOutlined, SunOutlined, SwapOutlined, TagsOutlined, TeamOutlined, ToolOutlined, UploadOutlined, } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useAllSettings } from '@/api/queries/useAllSettings'; import './AppSidebar.css'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const DONATE_URL = 'https://donate.sanaei.dev/'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const LOGOUT_KEY = '__logout__'; type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound'; const iconByName: Record = { dashboard: DashboardOutlined, inbound: ImportOutlined, team: TeamOutlined, groups: TagsOutlined, setting: SettingOutlined, tool: ToolOutlined, cluster: ClusterOutlined, logout: LogoutOutlined, apidocs: ApiOutlined, outbound: UploadOutlined, }; function readCollapsed(): boolean { try { return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'); } catch { return false; } } function DonateButton({ ariaLabel }: { ariaLabel: string }) { return ( ); } function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) { if (!version) return null; const label = `v${version}`; return ( {!collapsed && {label}} ); } function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: { id: string; isDark: boolean; isUltra: boolean; onCycle: () => void; ariaLabel: string; }) { const icon = !isDark ? : !isUltra ? : ; return ( ); } export default function AppSidebar() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); const navigate = useNavigate(); const { pathname, hash } = useLocation(); const { allSetting } = useAllSettings(); const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); const [collapsed, setCollapsed] = useState(() => readCollapsed()); const [drawerOpen, setDrawerOpen] = useState(false); const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light'; const panelVersion = window.X_UI_CUR_VER || ''; const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [ { key: '/', icon: 'dashboard', title: t('menu.dashboard') }, { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') }, { key: '/clients', icon: 'team', title: t('menu.clients') }, { key: '/groups', icon: 'groups', title: t('menu.groups') }, { key: '/nodes', icon: 'cluster', title: t('menu.nodes') }, { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') }, { key: '/settings', icon: 'setting', title: t('menu.settings') }, { key: '/xray', icon: 'tool', title: t('menu.xray') }, { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') }, { key: LOGOUT_KEY, icon: 'logout', title: t('logout') }, ], [t]); const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]); const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]); const settingsChildren = useMemo>(() => { const children: NonNullable = [ { key: '/settings#general', icon: , label: t('pages.settings.panelSettings') }, { key: '/settings#security', icon: , label: t('pages.settings.securitySettings') }, { key: '/settings#telegram', icon: , label: t('pages.settings.TGBotSettings') }, { key: '/settings#email', icon: , label: t('pages.settings.emailSettings') }, { key: '/settings#subscription', icon: , label: t('pages.settings.subSettings') }, ]; if (showSubFormats) { children.push({ key: '/settings#subscription-formats', icon: , label: 'Sub Formats' }); } return children; }, [t, showSubFormats]); const xrayChildren = useMemo>(() => [ { key: '/xray#basic', icon: , label: t('pages.xray.basicTemplate') }, { key: '/xray#routing', icon: , label: t('pages.xray.Routings') }, { key: '/xray#balancer', icon: , label: t('pages.xray.Balancers') }, { key: '/xray#dns', icon: , label: 'DNS' }, { key: '/xray#advanced', icon: , label: t('pages.xray.advancedTemplate') }, ], [t]); const settingsActive = pathname === '/settings'; const xrayActive = pathname === '/xray'; const selectedKey = settingsActive ? `/settings${hash || '#general'}` : xrayActive ? `/xray${hash || '#basic'}` : (pathname === '' ? '/' : pathname); // The Outbounds top-level item lives on /xray#outbound, so don't auto-open the // Xray Configs submenu for it. const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null; const [openKeys, setOpenKeys] = useState(() => (openSubmenu ? [openSubmenu] : [])); useEffect(() => { if (openSubmenu) { setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu])); } }, [openSubmenu]); const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] => items.map((tab) => { const Icon = iconByName[tab.icon]; if (tab.key === '/settings') { return { key: tab.key, icon: , label: tab.title, children: settingsChildren }; } if (tab.key === '/xray') { return { key: tab.key, icon: , label: tab.title, children: xrayChildren }; } return { key: tab.key, icon: , label: tab.title }; }), [settingsChildren, xrayChildren]); const openLink = useCallback(async (key: string) => { if (key === LOGOUT_KEY) { await HttpUtil.post('/logout'); window.location.href = window.X_UI_BASE_PATH || '/'; return; } navigate(key); }, [navigate]); const onMenuClick = useCallback>(({ key }) => { openLink(String(key)); }, [openLink]); const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => { if (type === 'clickTrigger') { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed)); setCollapsed(isCollapsed); } }, []); const cycleTheme = useCallback((id: string) => { pauseAnimationsUntilLeave(id); if (!isDark) { toggleTheme(); if (isUltra) toggleUltra(); } else if (!isUltra) { toggleUltra(); } else { toggleUltra(); toggleTheme(); } }, [isDark, isUltra, toggleTheme, toggleUltra]); return (
{collapsed ? '3X' : '3X-UI'}
{!collapsed && (
cycleTheme('theme-cycle')} ariaLabel={t('menu.theme')} />
)}
setOpenKeys(keys as string[])} className="sider-nav" items={toMenuItems(navItems)} onClick={onMenuClick} />
setDrawerOpen(false)} >
3X-UI
cycleTheme('theme-cycle-drawer')} ariaLabel={t('menu.theme')} />
setOpenKeys(keys as string[])} className="drawer-menu drawer-nav" items={toMenuItems(navItems)} onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }} /> { onMenuClick(info); setDrawerOpen(false); }} />
{!drawerOpen && ( )}
); }