import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentType, CSSProperties } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Drawer, Layout, Menu } from 'antd'; import type { MenuProps } from 'antd'; import { ApiOutlined, CloseOutlined, CloudServerOutlined, ClusterOutlined, CodeOutlined, DashboardOutlined, DatabaseOutlined, ExportOutlined, GithubOutlined, GlobalOutlined, HeartOutlined, ImportOutlined, LogoutOutlined, MailOutlined, MenuOutlined, MessageOutlined, MoonFilled, MoonOutlined, PushpinFilled, PushpinOutlined, ReadOutlined, SafetyOutlined, SettingOutlined, SunOutlined, SwapOutlined, TagsOutlined, TeamOutlined, ToolOutlined, } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import { formatPanelVersion } from '@/lib/panel-version'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useAllSettings } from '@/api/queries/useAllSettings'; import './AppSidebar.css'; const DONATE_URL = 'https://donate.sanaei.dev/'; const DOCS_URL = 'https://docs.sanaei.dev/'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const LOGOUT_KEY = '__logout__'; const RAIL_WIDTH = 72; const SIDER_WIDTH = 220; const SIDEBAR_PINNED_KEY = 'sidebar-pinned'; let hoveredAcrossRemounts = false; type IconName = | 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing'; const iconByName: Record = { dashboard: DashboardOutlined, inbound: ImportOutlined, team: TeamOutlined, groups: TagsOutlined, setting: SettingOutlined, tool: ToolOutlined, cluster: ClusterOutlined, hosts: GlobalOutlined, logout: LogoutOutlined, apidocs: ApiOutlined, outbound: ExportOutlined, routing: SwapOutlined, }; function DonateButton({ ariaLabel }: { ariaLabel: string }) { return ( ); } function DocsButton({ ariaLabel }: { ariaLabel: string }) { return ( ); } function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) { if (!version) return null; const label = formatPanelVersion(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 ( ); } function readSidebarPinned() { try { return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true'; } catch { return false; } } function saveSidebarPinned(pinned: boolean) { try { localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned)); } catch {} } 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 [hovered, setHovered] = useState(() => hoveredAcrossRemounts); const [pinned, setPinned] = useState(readSidebarPinned); const [drawerOpen, setDrawerOpen] = useState(false); const railCollapsed = !hovered && !pinned; const railStyle = useMemo( () => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties, [pinned], ); const rootRef = useRef(null); const updateHovered = useCallback((value: boolean) => { hoveredAcrossRemounts = value; setHovered(value); }, []); const togglePinned = useCallback(() => { const next = !pinned; saveSidebarPinned(next); setPinned(next); }, [pinned]); useEffect(() => { const timer = window.setTimeout(() => { const el = rootRef.current; if (el) updateHovered(el.matches(':hover')); }, 150); return () => window.clearTimeout(timer); }, [updateHovered]); 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: '/hosts', icon: 'hosts', title: t('menu.hosts') }, { key: '/outbound', icon: 'outbound', title: t('menu.outbounds') }, { key: '/routing', icon: 'routing', title: t('menu.routing') }, { 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: t('menu.subFormats'), }); } return children; }, [t, showSubFormats]); const xrayChildren = useMemo>( () => [ { key: '/xray#basic', icon: , label: t('pages.xray.basicTemplate') }, { 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; const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null; const [openKeys, setOpenKeys] = useState(() => (openSubmenu ? [openSubmenu] : [])); if (openSubmenu && !openKeys.includes(openSubmenu)) { setOpenKeys([...openKeys, 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, 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 cycleTheme = useCallback( (id: string) => { pauseAnimationsUntilLeave(id); if (!isDark) { toggleTheme(); if (isUltra) toggleUltra(); } else if (!isUltra) { toggleUltra(); } else { toggleUltra(); toggleTheme(); } }, [isDark, isUltra, toggleTheme, toggleUltra], ); return (
updateHovered(true)} onMouseLeave={() => updateHovered(false)} >
{railCollapsed ? '3X' : '3X-UI'}
{!railCollapsed && (
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 && ( )}
); }