AppSidebar.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import type { ComponentType, CSSProperties } from 'react';
  3. import { useLocation, useNavigate } from 'react-router';
  4. import { useTranslation } from 'react-i18next';
  5. import { Drawer, Layout, Menu } from 'antd';
  6. import type { MenuProps } from 'antd';
  7. import {
  8. ApiOutlined,
  9. ApartmentOutlined,
  10. CloseOutlined,
  11. CloudServerOutlined,
  12. ClusterOutlined,
  13. CodeOutlined,
  14. DashboardOutlined,
  15. DatabaseOutlined,
  16. ExportOutlined,
  17. GithubOutlined,
  18. GlobalOutlined,
  19. HeartOutlined,
  20. ImportOutlined,
  21. LogoutOutlined,
  22. MailOutlined,
  23. MenuOutlined,
  24. MessageOutlined,
  25. MoonFilled,
  26. MoonOutlined,
  27. PushpinFilled,
  28. PushpinOutlined,
  29. ReadOutlined,
  30. SafetyOutlined,
  31. SettingOutlined,
  32. SunOutlined,
  33. SwapOutlined,
  34. TagsOutlined,
  35. TeamOutlined,
  36. ToolOutlined,
  37. } from '@ant-design/icons';
  38. import { HttpUtil } from '@/utils';
  39. import { formatPanelVersion } from '@/lib/panel-version';
  40. import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
  41. import { useAllSettings } from '@/api/queries/useAllSettings';
  42. import './AppSidebar.css';
  43. const DONATE_URL = 'https://donate.sanaei.dev/';
  44. const DOCS_URL = 'https://docs.sanaei.dev/';
  45. const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
  46. const LOGOUT_KEY = '__logout__';
  47. const RAIL_WIDTH = 72;
  48. const SIDER_WIDTH = 220;
  49. const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
  50. let hoveredAcrossRemounts = false;
  51. type IconName =
  52. | 'dashboard'
  53. | 'inbound'
  54. | 'team'
  55. | 'groups'
  56. | 'setting'
  57. | 'tool'
  58. | 'cluster'
  59. | 'hosts'
  60. | 'logout'
  61. | 'apidocs'
  62. | 'outbound'
  63. | 'routing';
  64. const iconByName: Record<IconName, ComponentType> = {
  65. dashboard: DashboardOutlined,
  66. inbound: ImportOutlined,
  67. team: TeamOutlined,
  68. groups: TagsOutlined,
  69. setting: SettingOutlined,
  70. tool: ToolOutlined,
  71. cluster: ClusterOutlined,
  72. hosts: GlobalOutlined,
  73. logout: LogoutOutlined,
  74. apidocs: ApiOutlined,
  75. outbound: ExportOutlined,
  76. routing: SwapOutlined,
  77. };
  78. function DonateButton({ ariaLabel }: { ariaLabel: string }) {
  79. return (
  80. <a
  81. href={DONATE_URL}
  82. target="_blank"
  83. rel="noopener noreferrer"
  84. className="sidebar-donate"
  85. aria-label={ariaLabel}
  86. title={ariaLabel}
  87. >
  88. <HeartOutlined />
  89. </a>
  90. );
  91. }
  92. function DocsButton({ ariaLabel }: { ariaLabel: string }) {
  93. return (
  94. <a
  95. href={DOCS_URL}
  96. target="_blank"
  97. rel="noopener noreferrer"
  98. className="sidebar-docs"
  99. aria-label={ariaLabel}
  100. title={ariaLabel}
  101. >
  102. <ReadOutlined />
  103. </a>
  104. );
  105. }
  106. function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
  107. if (!version) return null;
  108. const label = formatPanelVersion(version);
  109. return (
  110. <a
  111. href={REPO_URL}
  112. target="_blank"
  113. rel="noopener noreferrer"
  114. className="sider-version"
  115. aria-label={`GitHub ${label}`}
  116. title={label}
  117. >
  118. <GithubOutlined />
  119. {!collapsed && <span className="sider-version-text">{label}</span>}
  120. </a>
  121. );
  122. }
  123. function ThemeCycleButton({
  124. id,
  125. isDark,
  126. isUltra,
  127. onCycle,
  128. ariaLabel,
  129. }: {
  130. id: string;
  131. isDark: boolean;
  132. isUltra: boolean;
  133. onCycle: () => void;
  134. ariaLabel: string;
  135. }) {
  136. const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
  137. return (
  138. <button
  139. id={id}
  140. type="button"
  141. className="sidebar-theme-cycle"
  142. aria-label={ariaLabel}
  143. title={ariaLabel}
  144. onClick={onCycle}
  145. >
  146. {icon}
  147. </button>
  148. );
  149. }
  150. function readSidebarPinned() {
  151. try {
  152. return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
  153. } catch {
  154. return false;
  155. }
  156. }
  157. function saveSidebarPinned(pinned: boolean) {
  158. try {
  159. localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
  160. } catch {}
  161. }
  162. export default function AppSidebar() {
  163. const { t } = useTranslation();
  164. const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
  165. const navigate = useNavigate();
  166. const { pathname, hash } = useLocation();
  167. const { allSetting } = useAllSettings();
  168. const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
  169. const showSubBalancers = !!allSetting.subJsonEnable;
  170. const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
  171. const [pinned, setPinned] = useState(readSidebarPinned);
  172. const [drawerOpen, setDrawerOpen] = useState(false);
  173. const railCollapsed = !hovered && !pinned;
  174. const railStyle = useMemo(
  175. () => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
  176. [pinned],
  177. );
  178. const rootRef = useRef<HTMLDivElement>(null);
  179. const updateHovered = useCallback((value: boolean) => {
  180. hoveredAcrossRemounts = value;
  181. setHovered(value);
  182. }, []);
  183. const togglePinned = useCallback(() => {
  184. const next = !pinned;
  185. saveSidebarPinned(next);
  186. setPinned(next);
  187. }, [pinned]);
  188. useEffect(() => {
  189. const timer = window.setTimeout(() => {
  190. const el = rootRef.current;
  191. if (el) updateHovered(el.matches(':hover'));
  192. }, 150);
  193. return () => window.clearTimeout(timer);
  194. }, [updateHovered]);
  195. const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
  196. const panelVersion = window.X_UI_CUR_VER || '';
  197. const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
  198. () => [
  199. { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
  200. { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
  201. { key: '/clients', icon: 'team', title: t('menu.clients') },
  202. { key: '/groups', icon: 'groups', title: t('menu.groups') },
  203. { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
  204. { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
  205. { key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
  206. { key: '/routing', icon: 'routing', title: t('menu.routing') },
  207. { key: '/settings', icon: 'setting', title: t('menu.settings') },
  208. { key: '/xray', icon: 'tool', title: t('menu.xray') },
  209. { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
  210. { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
  211. ],
  212. [t],
  213. );
  214. const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
  215. const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
  216. const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
  217. const children: NonNullable<MenuProps['items']> = [
  218. {
  219. key: '/settings#general',
  220. icon: <SettingOutlined />,
  221. label: t('pages.settings.panelSettings'),
  222. },
  223. {
  224. key: '/settings#security',
  225. icon: <SafetyOutlined />,
  226. label: t('pages.settings.securitySettings'),
  227. },
  228. {
  229. key: '/settings#telegram',
  230. icon: <MessageOutlined />,
  231. label: t('pages.settings.TGBotSettings'),
  232. },
  233. { key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
  234. {
  235. key: '/settings#subscription',
  236. icon: <CloudServerOutlined />,
  237. label: t('pages.settings.subSettings'),
  238. },
  239. ];
  240. if (showSubFormats) {
  241. children.push({
  242. key: '/settings#subscription-formats',
  243. icon: <CodeOutlined />,
  244. label: t('menu.subFormats'),
  245. });
  246. }
  247. if (showSubBalancers) {
  248. children.push({
  249. key: '/settings#subscription-balancers',
  250. icon: <ApartmentOutlined />,
  251. label: t('pages.settings.subBalancers.menu'),
  252. });
  253. }
  254. return children;
  255. }, [t, showSubFormats, showSubBalancers]);
  256. const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
  257. () => [
  258. { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
  259. { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
  260. { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
  261. { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
  262. ],
  263. [t],
  264. );
  265. const settingsActive = pathname === '/settings';
  266. const xrayActive = pathname === '/xray';
  267. const selectedKey = settingsActive
  268. ? `/settings${hash || '#general'}`
  269. : xrayActive
  270. ? `/xray${hash || '#basic'}`
  271. : pathname === ''
  272. ? '/'
  273. : pathname;
  274. const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
  275. const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
  276. if (openSubmenu && !openKeys.includes(openSubmenu)) {
  277. setOpenKeys([...openKeys, openSubmenu]);
  278. }
  279. const toMenuItems = useCallback(
  280. (items: typeof tabs): MenuProps['items'] =>
  281. items.map((tab) => {
  282. const Icon = iconByName[tab.icon];
  283. if (tab.key === '/settings') {
  284. return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
  285. }
  286. if (tab.key === '/xray') {
  287. return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
  288. }
  289. return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
  290. }),
  291. [settingsChildren, xrayChildren],
  292. );
  293. const openLink = useCallback(
  294. async (key: string) => {
  295. if (key === LOGOUT_KEY) {
  296. await HttpUtil.post('/logout');
  297. window.location.href = window.X_UI_BASE_PATH || '/';
  298. return;
  299. }
  300. navigate(key);
  301. },
  302. [navigate],
  303. );
  304. const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
  305. ({ key }) => {
  306. openLink(String(key));
  307. },
  308. [openLink],
  309. );
  310. const cycleTheme = useCallback(
  311. (id: string) => {
  312. pauseAnimationsUntilLeave(id);
  313. if (!isDark) {
  314. toggleTheme();
  315. if (isUltra) toggleUltra();
  316. } else if (!isUltra) {
  317. toggleUltra();
  318. } else {
  319. toggleUltra();
  320. toggleTheme();
  321. }
  322. },
  323. [isDark, isUltra, toggleTheme, toggleUltra],
  324. );
  325. return (
  326. <div
  327. ref={rootRef}
  328. className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
  329. style={railStyle}
  330. onMouseEnter={() => updateHovered(true)}
  331. onMouseLeave={() => updateHovered(false)}
  332. >
  333. <Layout.Sider
  334. theme={currentTheme}
  335. width={SIDER_WIDTH}
  336. collapsedWidth={RAIL_WIDTH}
  337. collapsed={railCollapsed}
  338. >
  339. <div className="sider-brand">
  340. <div className="brand-block">
  341. <span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
  342. </div>
  343. {!railCollapsed && (
  344. <div className="brand-actions">
  345. <button
  346. type="button"
  347. className="sidebar-pin"
  348. aria-label={t('menu.pinSidebar')}
  349. aria-pressed={pinned}
  350. title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
  351. onClick={togglePinned}
  352. >
  353. {pinned ? <PushpinFilled /> : <PushpinOutlined />}
  354. </button>
  355. <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
  356. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  357. <ThemeCycleButton
  358. id="theme-cycle"
  359. isDark={isDark}
  360. isUltra={isUltra}
  361. onCycle={() => cycleTheme('theme-cycle')}
  362. ariaLabel={t('menu.theme')}
  363. />
  364. </div>
  365. )}
  366. </div>
  367. <Menu
  368. theme={currentTheme}
  369. mode="inline"
  370. selectedKeys={[selectedKey]}
  371. openKeys={railCollapsed ? undefined : openKeys}
  372. onOpenChange={(keys) => setOpenKeys(keys as string[])}
  373. className="sider-nav"
  374. items={toMenuItems(navItems)}
  375. onClick={onMenuClick}
  376. />
  377. <Menu
  378. theme={currentTheme}
  379. mode="inline"
  380. selectedKeys={[selectedKey]}
  381. className="sider-utility"
  382. items={toMenuItems(utilItems)}
  383. onClick={onMenuClick}
  384. />
  385. <div className="sider-footer">
  386. <VersionBadge version={panelVersion} collapsed={railCollapsed} />
  387. </div>
  388. </Layout.Sider>
  389. <Drawer
  390. placement="left"
  391. closable={false}
  392. open={drawerOpen}
  393. rootClassName={currentTheme}
  394. size="min(82vw, 320px)"
  395. styles={{
  396. wrapper: { padding: 0 },
  397. body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
  398. header: { display: 'none' },
  399. }}
  400. onClose={() => setDrawerOpen(false)}
  401. >
  402. <div className="drawer-header">
  403. <div className="brand-block">
  404. <span className="drawer-brand">3X-UI</span>
  405. </div>
  406. <div className="drawer-header-actions">
  407. <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
  408. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  409. <ThemeCycleButton
  410. id="theme-cycle-drawer"
  411. isDark={isDark}
  412. isUltra={isUltra}
  413. onCycle={() => cycleTheme('theme-cycle-drawer')}
  414. ariaLabel={t('menu.theme')}
  415. />
  416. <button
  417. className="drawer-close"
  418. type="button"
  419. aria-label={t('close')}
  420. onClick={() => setDrawerOpen(false)}
  421. >
  422. <CloseOutlined />
  423. </button>
  424. </div>
  425. </div>
  426. <Menu
  427. theme={currentTheme}
  428. mode="inline"
  429. selectedKeys={[selectedKey]}
  430. openKeys={openKeys}
  431. onOpenChange={(keys) => setOpenKeys(keys as string[])}
  432. className="drawer-menu drawer-nav"
  433. items={toMenuItems(navItems)}
  434. onClick={(info) => {
  435. onMenuClick(info);
  436. setDrawerOpen(false);
  437. }}
  438. />
  439. <Menu
  440. theme={currentTheme}
  441. mode="inline"
  442. selectedKeys={[selectedKey]}
  443. className="drawer-menu drawer-utility"
  444. items={toMenuItems(utilItems)}
  445. onClick={(info) => {
  446. onMenuClick(info);
  447. setDrawerOpen(false);
  448. }}
  449. />
  450. <div className="drawer-footer">
  451. <VersionBadge version={panelVersion} />
  452. </div>
  453. </Drawer>
  454. {!drawerOpen && (
  455. <button
  456. className="drawer-handle"
  457. type="button"
  458. aria-label={t('menu.openMenu')}
  459. onClick={() => setDrawerOpen(true)}
  460. >
  461. <MenuOutlined />
  462. </button>
  463. )}
  464. </div>
  465. );
  466. }