AppSidebar.tsx 16 KB

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