AppSidebar.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import type { ComponentType } from 'react';
  3. import { useLocation, useNavigate } from 'react-router-dom';
  4. import { useTranslation } from 'react-i18next';
  5. import { Drawer, Layout, Menu } from 'antd';
  6. import type { MenuProps } from 'antd';
  7. import {
  8. ApiOutlined,
  9. CloseOutlined,
  10. CloudServerOutlined,
  11. ClusterOutlined,
  12. CodeOutlined,
  13. DashboardOutlined,
  14. DatabaseOutlined,
  15. ExportOutlined,
  16. GithubOutlined,
  17. GlobalOutlined,
  18. HeartOutlined,
  19. ImportOutlined,
  20. LogoutOutlined,
  21. MailOutlined,
  22. MenuOutlined,
  23. MessageOutlined,
  24. MoonFilled,
  25. MoonOutlined,
  26. SafetyOutlined,
  27. SettingOutlined,
  28. SunOutlined,
  29. SwapOutlined,
  30. TagsOutlined,
  31. TeamOutlined,
  32. ToolOutlined,
  33. } from '@ant-design/icons';
  34. import { HttpUtil } from '@/utils';
  35. import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
  36. import { useAllSettings } from '@/api/queries/useAllSettings';
  37. import './AppSidebar.css';
  38. const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
  39. const DONATE_URL = 'https://donate.sanaei.dev/';
  40. const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
  41. const LOGOUT_KEY = '__logout__';
  42. type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
  43. const iconByName: Record<IconName, ComponentType> = {
  44. dashboard: DashboardOutlined,
  45. inbound: ImportOutlined,
  46. team: TeamOutlined,
  47. groups: TagsOutlined,
  48. setting: SettingOutlined,
  49. tool: ToolOutlined,
  50. cluster: ClusterOutlined,
  51. hosts: GlobalOutlined,
  52. logout: LogoutOutlined,
  53. apidocs: ApiOutlined,
  54. outbound: ExportOutlined,
  55. };
  56. function readCollapsed(): boolean {
  57. try {
  58. return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
  59. } catch {
  60. return false;
  61. }
  62. }
  63. function DonateButton({ ariaLabel }: { ariaLabel: string }) {
  64. return (
  65. <a
  66. href={DONATE_URL}
  67. target="_blank"
  68. rel="noopener noreferrer"
  69. className="sidebar-donate"
  70. aria-label={ariaLabel}
  71. title={ariaLabel}
  72. >
  73. <HeartOutlined />
  74. </a>
  75. );
  76. }
  77. function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
  78. if (!version) return null;
  79. const label = `v${version}`;
  80. return (
  81. <a
  82. href={REPO_URL}
  83. target="_blank"
  84. rel="noopener noreferrer"
  85. className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
  86. aria-label={`GitHub ${label}`}
  87. title={label}
  88. >
  89. <GithubOutlined />
  90. {!collapsed && <span className="sider-version-text">{label}</span>}
  91. </a>
  92. );
  93. }
  94. function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
  95. id: string;
  96. isDark: boolean;
  97. isUltra: boolean;
  98. onCycle: () => void;
  99. ariaLabel: string;
  100. }) {
  101. const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
  102. return (
  103. <button
  104. id={id}
  105. type="button"
  106. className="sidebar-theme-cycle"
  107. aria-label={ariaLabel}
  108. title={ariaLabel}
  109. onClick={onCycle}
  110. >
  111. {icon}
  112. </button>
  113. );
  114. }
  115. export default function AppSidebar() {
  116. const { t } = useTranslation();
  117. const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
  118. const navigate = useNavigate();
  119. const { pathname, hash } = useLocation();
  120. const { allSetting } = useAllSettings();
  121. const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
  122. const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
  123. const [drawerOpen, setDrawerOpen] = useState(false);
  124. const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
  125. const panelVersion = window.X_UI_CUR_VER || '';
  126. const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
  127. { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
  128. { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
  129. { key: '/clients', icon: 'team', title: t('menu.clients') },
  130. { key: '/groups', icon: 'groups', title: t('menu.groups') },
  131. { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
  132. { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
  133. { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
  134. { key: '/settings', icon: 'setting', title: t('menu.settings') },
  135. { key: '/xray', icon: 'tool', title: t('menu.xray') },
  136. { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
  137. { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
  138. ], [t]);
  139. const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
  140. const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
  141. const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
  142. const children: NonNullable<MenuProps['items']> = [
  143. { key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
  144. { key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
  145. { key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
  146. { key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
  147. { key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
  148. ];
  149. if (showSubFormats) {
  150. children.push({ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: 'Sub Formats' });
  151. }
  152. return children;
  153. }, [t, showSubFormats]);
  154. const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
  155. { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
  156. { key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
  157. { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
  158. { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
  159. { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
  160. ], [t]);
  161. const settingsActive = pathname === '/settings';
  162. const xrayActive = pathname === '/xray';
  163. const selectedKey = settingsActive
  164. ? `/settings${hash || '#general'}`
  165. : xrayActive
  166. ? `/xray${hash || '#basic'}`
  167. : (pathname === '' ? '/' : pathname);
  168. // The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
  169. // Xray Configs submenu for it.
  170. const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
  171. const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
  172. useEffect(() => {
  173. if (openSubmenu) {
  174. setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu]));
  175. }
  176. }, [openSubmenu]);
  177. const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
  178. items.map((tab) => {
  179. const Icon = iconByName[tab.icon];
  180. if (tab.key === '/settings') {
  181. return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
  182. }
  183. if (tab.key === '/xray') {
  184. return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
  185. }
  186. return { key: tab.key, icon: <Icon />, label: tab.title };
  187. }),
  188. [settingsChildren, xrayChildren]);
  189. const openLink = useCallback(async (key: string) => {
  190. if (key === LOGOUT_KEY) {
  191. await HttpUtil.post('/logout');
  192. window.location.href = window.X_UI_BASE_PATH || '/';
  193. return;
  194. }
  195. navigate(key);
  196. }, [navigate]);
  197. const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
  198. openLink(String(key));
  199. }, [openLink]);
  200. const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
  201. if (type === 'clickTrigger') {
  202. localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
  203. setCollapsed(isCollapsed);
  204. }
  205. }, []);
  206. const cycleTheme = useCallback((id: string) => {
  207. pauseAnimationsUntilLeave(id);
  208. if (!isDark) {
  209. toggleTheme();
  210. if (isUltra) toggleUltra();
  211. } else if (!isUltra) {
  212. toggleUltra();
  213. } else {
  214. toggleUltra();
  215. toggleTheme();
  216. }
  217. }, [isDark, isUltra, toggleTheme, toggleUltra]);
  218. return (
  219. <div className="ant-sidebar">
  220. <Layout.Sider
  221. theme={currentTheme}
  222. width={220}
  223. collapsible
  224. collapsed={collapsed}
  225. breakpoint="md"
  226. onCollapse={onSiderCollapse}
  227. >
  228. <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
  229. <div className="brand-block">
  230. <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
  231. </div>
  232. {!collapsed && (
  233. <div className="brand-actions">
  234. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  235. <ThemeCycleButton
  236. id="theme-cycle"
  237. isDark={isDark}
  238. isUltra={isUltra}
  239. onCycle={() => cycleTheme('theme-cycle')}
  240. ariaLabel={t('menu.theme')}
  241. />
  242. </div>
  243. )}
  244. </div>
  245. <Menu
  246. theme={currentTheme}
  247. mode="inline"
  248. selectedKeys={[selectedKey]}
  249. openKeys={collapsed ? undefined : openKeys}
  250. onOpenChange={(keys) => setOpenKeys(keys as string[])}
  251. className="sider-nav"
  252. items={toMenuItems(navItems)}
  253. onClick={onMenuClick}
  254. />
  255. <Menu
  256. theme={currentTheme}
  257. mode="inline"
  258. selectedKeys={[selectedKey]}
  259. className="sider-utility"
  260. items={toMenuItems(utilItems)}
  261. onClick={onMenuClick}
  262. />
  263. <div className="sider-footer">
  264. <VersionBadge version={panelVersion} collapsed={collapsed} />
  265. </div>
  266. </Layout.Sider>
  267. <Drawer
  268. placement="left"
  269. closable={false}
  270. open={drawerOpen}
  271. rootClassName={currentTheme}
  272. size="min(82vw, 320px)"
  273. styles={{
  274. wrapper: { padding: 0 },
  275. body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
  276. header: { display: 'none' },
  277. }}
  278. onClose={() => setDrawerOpen(false)}
  279. >
  280. <div className="drawer-header">
  281. <div className="brand-block">
  282. <span className="drawer-brand">3X-UI</span>
  283. </div>
  284. <div className="drawer-header-actions">
  285. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  286. <ThemeCycleButton
  287. id="theme-cycle-drawer"
  288. isDark={isDark}
  289. isUltra={isUltra}
  290. onCycle={() => cycleTheme('theme-cycle-drawer')}
  291. ariaLabel={t('menu.theme')}
  292. />
  293. <button
  294. className="drawer-close"
  295. type="button"
  296. aria-label={t('close')}
  297. onClick={() => setDrawerOpen(false)}
  298. >
  299. <CloseOutlined />
  300. </button>
  301. </div>
  302. </div>
  303. <Menu
  304. theme={currentTheme}
  305. mode="inline"
  306. selectedKeys={[selectedKey]}
  307. openKeys={openKeys}
  308. onOpenChange={(keys) => setOpenKeys(keys as string[])}
  309. className="drawer-menu drawer-nav"
  310. items={toMenuItems(navItems)}
  311. onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
  312. />
  313. <Menu
  314. theme={currentTheme}
  315. mode="inline"
  316. selectedKeys={[selectedKey]}
  317. className="drawer-menu drawer-utility"
  318. items={toMenuItems(utilItems)}
  319. onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
  320. />
  321. <div className="drawer-footer">
  322. <VersionBadge version={panelVersion} />
  323. </div>
  324. </Drawer>
  325. {!drawerOpen && (
  326. <button
  327. className="drawer-handle"
  328. type="button"
  329. aria-label={t('menu.dashboard')}
  330. onClick={() => setDrawerOpen(true)}
  331. >
  332. <MenuOutlined />
  333. </button>
  334. )}
  335. </div>
  336. );
  337. }