AppSidebar.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { useCallback, 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. ClusterOutlined,
  10. CloseOutlined,
  11. DashboardOutlined,
  12. HeartOutlined,
  13. LogoutOutlined,
  14. MenuOutlined,
  15. MoonFilled,
  16. MoonOutlined,
  17. SettingOutlined,
  18. SunOutlined,
  19. TeamOutlined,
  20. ToolOutlined,
  21. UserOutlined,
  22. } from '@ant-design/icons';
  23. import { HttpUtil } from '@/utils';
  24. import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
  25. import './AppSidebar.css';
  26. const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
  27. const DONATE_URL = 'https://donate.sanaei.dev/';
  28. const LOGOUT_KEY = '__logout__';
  29. type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
  30. const iconByName: Record<IconName, ComponentType> = {
  31. dashboard: DashboardOutlined,
  32. user: UserOutlined,
  33. team: TeamOutlined,
  34. setting: SettingOutlined,
  35. tool: ToolOutlined,
  36. cluster: ClusterOutlined,
  37. logout: LogoutOutlined,
  38. apidocs: ApiOutlined,
  39. };
  40. function readCollapsed(): boolean {
  41. try {
  42. return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
  43. } catch {
  44. return false;
  45. }
  46. }
  47. function DonateButton({ ariaLabel }: { ariaLabel: string }) {
  48. return (
  49. <a
  50. href={DONATE_URL}
  51. target="_blank"
  52. rel="noopener noreferrer"
  53. className="sidebar-donate"
  54. aria-label={ariaLabel}
  55. title={ariaLabel}
  56. >
  57. <HeartOutlined />
  58. </a>
  59. );
  60. }
  61. function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
  62. id: string;
  63. isDark: boolean;
  64. isUltra: boolean;
  65. onCycle: () => void;
  66. ariaLabel: string;
  67. }) {
  68. const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
  69. return (
  70. <button
  71. id={id}
  72. type="button"
  73. className="sidebar-theme-cycle"
  74. aria-label={ariaLabel}
  75. title={ariaLabel}
  76. onClick={onCycle}
  77. >
  78. {icon}
  79. </button>
  80. );
  81. }
  82. export default function AppSidebar() {
  83. const { t } = useTranslation();
  84. const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
  85. const navigate = useNavigate();
  86. const { pathname } = useLocation();
  87. const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
  88. const [drawerOpen, setDrawerOpen] = useState(false);
  89. const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
  90. const panelVersion = window.X_UI_CUR_VER || '';
  91. const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
  92. { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
  93. { key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
  94. { key: '/clients', icon: 'team', title: t('menu.clients') },
  95. { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
  96. { key: '/settings', icon: 'setting', title: t('menu.settings') },
  97. { key: '/xray', icon: 'tool', title: t('menu.xray') },
  98. { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
  99. { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
  100. ], [t]);
  101. const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
  102. const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
  103. const selectedKey = pathname === '' ? '/' : pathname;
  104. const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
  105. items.map((tab) => {
  106. const Icon = iconByName[tab.icon];
  107. return {
  108. key: tab.key,
  109. icon: <Icon />,
  110. label: tab.title,
  111. };
  112. }),
  113. []);
  114. const openLink = useCallback(async (key: string) => {
  115. if (key === LOGOUT_KEY) {
  116. await HttpUtil.post('/logout');
  117. window.location.href = window.X_UI_BASE_PATH || '/';
  118. return;
  119. }
  120. navigate(key);
  121. }, [navigate]);
  122. const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
  123. openLink(String(key));
  124. }, [openLink]);
  125. const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
  126. if (type === 'clickTrigger') {
  127. localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
  128. setCollapsed(isCollapsed);
  129. }
  130. }, []);
  131. const cycleTheme = useCallback((id: string) => {
  132. pauseAnimationsUntilLeave(id);
  133. if (!isDark) {
  134. toggleTheme();
  135. if (isUltra) toggleUltra();
  136. } else if (!isUltra) {
  137. toggleUltra();
  138. } else {
  139. toggleUltra();
  140. toggleTheme();
  141. }
  142. }, [isDark, isUltra, toggleTheme, toggleUltra]);
  143. return (
  144. <div className="ant-sidebar">
  145. <Layout.Sider
  146. theme={currentTheme}
  147. collapsible
  148. collapsed={collapsed}
  149. breakpoint="md"
  150. onCollapse={onSiderCollapse}
  151. >
  152. <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
  153. <div className="brand-block">
  154. <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
  155. {!collapsed && panelVersion && (
  156. <span className="brand-version">v{panelVersion}</span>
  157. )}
  158. </div>
  159. {!collapsed && (
  160. <div className="brand-actions">
  161. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  162. <ThemeCycleButton
  163. id="theme-cycle"
  164. isDark={isDark}
  165. isUltra={isUltra}
  166. onCycle={() => cycleTheme('theme-cycle')}
  167. ariaLabel={t('menu.theme')}
  168. />
  169. </div>
  170. )}
  171. </div>
  172. <Menu
  173. theme={currentTheme}
  174. mode="inline"
  175. selectedKeys={[selectedKey]}
  176. className="sider-nav"
  177. items={toMenuItems(navItems)}
  178. onClick={onMenuClick}
  179. />
  180. <Menu
  181. theme={currentTheme}
  182. mode="inline"
  183. selectedKeys={[selectedKey]}
  184. className="sider-utility"
  185. items={toMenuItems(utilItems)}
  186. onClick={onMenuClick}
  187. />
  188. </Layout.Sider>
  189. <Drawer
  190. placement="left"
  191. closable={false}
  192. open={drawerOpen}
  193. rootClassName={currentTheme}
  194. size="min(82vw, 320px)"
  195. styles={{
  196. wrapper: { padding: 0 },
  197. body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
  198. header: { display: 'none' },
  199. }}
  200. onClose={() => setDrawerOpen(false)}
  201. >
  202. <div className="drawer-header">
  203. <div className="brand-block">
  204. <span className="drawer-brand">3X-UI</span>
  205. {panelVersion && <span className="brand-version">v{panelVersion}</span>}
  206. </div>
  207. <div className="drawer-header-actions">
  208. <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
  209. <ThemeCycleButton
  210. id="theme-cycle-drawer"
  211. isDark={isDark}
  212. isUltra={isUltra}
  213. onCycle={() => cycleTheme('theme-cycle-drawer')}
  214. ariaLabel={t('menu.theme')}
  215. />
  216. <button
  217. className="drawer-close"
  218. type="button"
  219. aria-label={t('close')}
  220. onClick={() => setDrawerOpen(false)}
  221. >
  222. <CloseOutlined />
  223. </button>
  224. </div>
  225. </div>
  226. <Menu
  227. theme={currentTheme}
  228. mode="inline"
  229. selectedKeys={[selectedKey]}
  230. className="drawer-menu drawer-nav"
  231. items={toMenuItems(navItems)}
  232. onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
  233. />
  234. <Menu
  235. theme={currentTheme}
  236. mode="inline"
  237. selectedKeys={[selectedKey]}
  238. className="drawer-menu drawer-utility"
  239. items={toMenuItems(utilItems)}
  240. onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
  241. />
  242. </Drawer>
  243. {!drawerOpen && (
  244. <button
  245. className="drawer-handle"
  246. type="button"
  247. aria-label={t('menu.dashboard')}
  248. onClick={() => setDrawerOpen(true)}
  249. >
  250. <MenuOutlined />
  251. </button>
  252. )}
  253. </div>
  254. );
  255. }