| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- import { useCallback, useMemo, useState } from 'react';
- import type { ComponentType } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Drawer, Layout, Menu } from 'antd';
- import type { MenuProps } from 'antd';
- import {
- ApiOutlined,
- ClusterOutlined,
- CloseOutlined,
- DashboardOutlined,
- HeartOutlined,
- LogoutOutlined,
- MenuOutlined,
- SettingOutlined,
- TeamOutlined,
- ToolOutlined,
- UserOutlined,
- } from '@ant-design/icons';
- import { HttpUtil } from '@/utils';
- import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
- import './AppSidebar.css';
- const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
- const DONATE_URL = 'https://donate.sanaei.dev/';
- interface AppSidebarProps {
- basePath?: string;
- requestUri?: string;
- }
- type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
- const iconByName: Record<IconName, ComponentType> = {
- dashboard: DashboardOutlined,
- user: UserOutlined,
- team: TeamOutlined,
- setting: SettingOutlined,
- tool: ToolOutlined,
- cluster: ClusterOutlined,
- logout: LogoutOutlined,
- apidocs: ApiOutlined,
- };
- function readCollapsed(): boolean {
- try {
- return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
- } catch {
- return false;
- }
- }
- function DonateButton({ ariaLabel }: { ariaLabel: string }) {
- return (
- <a
- href={DONATE_URL}
- target="_blank"
- rel="noopener noreferrer"
- className="sidebar-donate"
- aria-label={ariaLabel}
- title={ariaLabel}
- >
- <HeartOutlined />
- </a>
- );
- }
- function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
- id: string;
- isDark: boolean;
- isUltra: boolean;
- onCycle: () => void;
- ariaLabel: string;
- }) {
- return (
- <button
- id={id}
- type="button"
- className="sidebar-theme-cycle"
- aria-label={ariaLabel}
- title={ariaLabel}
- onClick={onCycle}
- >
- {!isDark ? (
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
- <circle cx="12" cy="12" r="4" />
- <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" />
- </svg>
- ) : !isUltra ? (
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
- </svg>
- ) : (
- <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
- <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" />
- </svg>
- )}
- </button>
- );
- }
- export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
- const { t } = useTranslation();
- const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
- const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
- const [drawerOpen, setDrawerOpen] = useState(false);
- const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
- const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
- const panelVersion = window.X_UI_CUR_VER || '';
- const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
- { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
- { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
- { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
- { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
- { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
- { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
- { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
- { key: 'logout', icon: 'logout', title: t('logout') },
- ], [prefix, t]);
- const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
- const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
- const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
- items.map((tab) => {
- const Icon = iconByName[tab.icon];
- return {
- key: tab.key,
- icon: <Icon />,
- label: tab.title,
- };
- }),
- []);
- const openLink = useCallback(async (key: string) => {
- if (key === 'logout') {
- await HttpUtil.post('/logout');
- window.location.href = basePath || '/';
- return;
- }
- if (key.startsWith('http')) {
- window.open(key);
- } else {
- window.location.href = key;
- }
- }, [basePath]);
- const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
- openLink(String(key));
- }, [openLink]);
- const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
- if (type === 'clickTrigger') {
- localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
- setCollapsed(isCollapsed);
- }
- }, []);
- const cycleTheme = useCallback((id: string) => {
- pauseAnimationsUntilLeave(id);
- if (!isDark) {
- toggleTheme();
- if (isUltra) toggleUltra();
- } else if (!isUltra) {
- toggleUltra();
- } else {
- toggleUltra();
- toggleTheme();
- }
- }, [isDark, isUltra, toggleTheme, toggleUltra]);
- return (
- <div className="ant-sidebar">
- <Layout.Sider
- theme={currentTheme}
- collapsible
- collapsed={collapsed}
- breakpoint="md"
- onCollapse={onSiderCollapse}
- >
- <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
- <div className="brand-block">
- <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
- {!collapsed && panelVersion && (
- <span className="brand-version">v{panelVersion}</span>
- )}
- </div>
- {!collapsed && (
- <div className="brand-actions">
- <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
- <ThemeCycleButton
- id="theme-cycle"
- isDark={isDark}
- isUltra={isUltra}
- onCycle={() => cycleTheme('theme-cycle')}
- ariaLabel={t('menu.theme')}
- />
- </div>
- )}
- </div>
- <Menu
- theme={currentTheme}
- mode="inline"
- selectedKeys={[requestUri]}
- className="sider-nav"
- items={toMenuItems(navItems)}
- onClick={onMenuClick}
- />
- <Menu
- theme={currentTheme}
- mode="inline"
- selectedKeys={[requestUri]}
- className="sider-utility"
- items={toMenuItems(utilItems)}
- onClick={onMenuClick}
- />
- </Layout.Sider>
- <Drawer
- placement="left"
- closable={false}
- open={drawerOpen}
- rootClassName={currentTheme}
- size="min(82vw, 320px)"
- styles={{
- wrapper: { padding: 0 },
- body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
- header: { display: 'none' },
- }}
- onClose={() => setDrawerOpen(false)}
- >
- <div className="drawer-header">
- <div className="brand-block">
- <span className="drawer-brand">3X-UI</span>
- {panelVersion && <span className="brand-version">v{panelVersion}</span>}
- </div>
- <div className="drawer-header-actions">
- <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
- <ThemeCycleButton
- id="theme-cycle-drawer"
- isDark={isDark}
- isUltra={isUltra}
- onCycle={() => cycleTheme('theme-cycle-drawer')}
- ariaLabel={t('menu.theme')}
- />
- <button
- className="drawer-close"
- type="button"
- aria-label={t('close')}
- onClick={() => setDrawerOpen(false)}
- >
- <CloseOutlined />
- </button>
- </div>
- </div>
- <Menu
- theme={currentTheme}
- mode="inline"
- selectedKeys={[requestUri]}
- className="drawer-menu drawer-nav"
- items={toMenuItems(navItems)}
- onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
- />
- <Menu
- theme={currentTheme}
- mode="inline"
- selectedKeys={[requestUri]}
- className="drawer-menu drawer-utility"
- items={toMenuItems(utilItems)}
- onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
- />
- </Drawer>
- {!drawerOpen && (
- <button
- className="drawer-handle"
- type="button"
- aria-label={t('menu.dashboard')}
- onClick={() => setDrawerOpen(true)}
- >
- <MenuOutlined />
- </button>
- )}
- </div>
- );
- }
|