AppSidebar.tsx 12 KB

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