AppSidebar.tsx 12 KB

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