AppSidebar.tsx 9.0 KB

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