mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
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,
|
|
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';
|
|
|
|
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 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 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' : ''}`}>
|
|
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
|
{!collapsed && (
|
|
<ThemeCycleButton
|
|
id="theme-cycle"
|
|
isDark={isDark}
|
|
isUltra={isUltra}
|
|
onCycle={() => cycleTheme('theme-cycle')}
|
|
ariaLabel={t('menu.theme')}
|
|
/>
|
|
)}
|
|
</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}
|
|
width="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">
|
|
<span className="drawer-brand">3X-UI</span>
|
|
<div className="drawer-header-actions">
|
|
<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>
|
|
);
|
|
}
|