From 56c9c0719f366ec8c5a2466170903350dcf16204 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 21:26:28 +0200 Subject: [PATCH] refactor(frontend): port api-docs to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- frontend/api-docs.html | 2 +- frontend/src/components/AppSidebar.css | 225 +++++++ frontend/src/components/AppSidebar.tsx | 260 ++++++++ frontend/src/entries/api-docs.js | 21 - frontend/src/entries/api-docs.tsx | 28 + frontend/src/pages/api-docs/ApiDocsPage.css | 292 +++++++++ frontend/src/pages/api-docs/ApiDocsPage.tsx | 246 ++++++++ frontend/src/pages/api-docs/ApiDocsPage.vue | 561 ------------------ .../api-docs/{CodeBlock.vue => CodeBlock.css} | 67 --- frontend/src/pages/api-docs/CodeBlock.tsx | 66 +++ frontend/src/pages/api-docs/EndpointRow.css | 93 +++ frontend/src/pages/api-docs/EndpointRow.tsx | 84 +++ frontend/src/pages/api-docs/EndpointRow.vue | 172 ------ ...ndpointSection.vue => EndpointSection.css} | 67 +-- .../src/pages/api-docs/EndpointSection.tsx | 90 +++ 15 files changed, 1387 insertions(+), 887 deletions(-) create mode 100644 frontend/src/components/AppSidebar.css create mode 100644 frontend/src/components/AppSidebar.tsx delete mode 100644 frontend/src/entries/api-docs.js create mode 100644 frontend/src/entries/api-docs.tsx create mode 100644 frontend/src/pages/api-docs/ApiDocsPage.css create mode 100644 frontend/src/pages/api-docs/ApiDocsPage.tsx delete mode 100644 frontend/src/pages/api-docs/ApiDocsPage.vue rename frontend/src/pages/api-docs/{CodeBlock.vue => CodeBlock.css} (54%) create mode 100644 frontend/src/pages/api-docs/CodeBlock.tsx create mode 100644 frontend/src/pages/api-docs/EndpointRow.css create mode 100644 frontend/src/pages/api-docs/EndpointRow.tsx delete mode 100644 frontend/src/pages/api-docs/EndpointRow.vue rename frontend/src/pages/api-docs/{EndpointSection.vue => EndpointSection.css} (51%) create mode 100644 frontend/src/pages/api-docs/EndpointSection.tsx diff --git a/frontend/api-docs.html b/frontend/api-docs.html index 65ee57f7..e2113b94 100644 --- a/frontend/api-docs.html +++ b/frontend/api-docs.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/src/components/AppSidebar.css b/frontend/src/components/AppSidebar.css new file mode 100644 index 00000000..1c56bbf3 --- /dev/null +++ b/frontend/src/components/AppSidebar.css @@ -0,0 +1,225 @@ +.ant-sidebar > .ant-layout-sider { + position: sticky; + top: 0; + height: 100vh; + align-self: flex-start; +} + +.sider-brand, +.drawer-brand { + font-weight: 600; + font-size: 18px; + letter-spacing: 0.5px; + color: rgba(0, 0, 0, 0.88); +} + +.sider-brand { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 14px; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); + user-select: none; +} + +.sider-brand-collapsed { + justify-content: center; + font-size: 16px; + padding: 14px 4px; + letter-spacing: 0; +} + +.brand-text { + flex: 1 1 auto; +} + +.sider-brand-collapsed .brand-text { + flex: 0 0 auto; +} + +.sidebar-theme-cycle { + background: transparent; + border: none; + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: rgba(0, 0, 0, 0.75); + padding: 0; + flex-shrink: 0; + transition: background-color 0.2s, transform 0.15s, color 0.2s; +} + +.sidebar-theme-cycle:hover, +.sidebar-theme-cycle:focus-visible { + background-color: rgba(64, 150, 255, 0.1); + color: #4096ff; + transform: scale(1.08); + outline: none; +} + +.sidebar-theme-cycle svg { + width: 16px; + height: 16px; +} + +.drawer-header-actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.drawer-handle { + position: fixed; + top: 12px; + left: 12px; + z-index: 1100; + background: rgba(0, 0, 0, 0.55); + color: #fff; + border: none; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); +} + +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); +} + +.drawer-close { + background: transparent; + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + color: rgba(0, 0, 0, 0.65); +} + +.drawer-close:hover, +.drawer-close:focus-visible { + background: rgba(128, 128, 128, 0.18); +} + +.drawer-menu .ant-menu-item { + height: 48px; + line-height: 48px; + margin: 0; + border-radius: 0; +} + +.drawer-menu .ant-menu-item .anticon { + font-size: 16px; +} + +.drawer-utility { + margin-top: auto; + border-top: 1px solid rgba(128, 128, 128, 0.15); +} + +.ant-sidebar > .ant-layout-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + height: 100%; +} + +.sider-nav { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.sider-utility { + flex: 0 0 auto; + border-top: 1px solid rgba(128, 128, 128, 0.15); +} + +@media (max-width: 768px) { + .drawer-handle { + display: inline-flex; + } + + .ant-sidebar > .ant-layout-sider .ant-layout-sider-children, + .ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger { + display: none; + } + + .ant-sidebar > .ant-layout-sider { + flex: 0 0 0 !important; + max-width: 0 !important; + min-width: 0 !important; + width: 0 !important; + } +} + +body.dark .drawer-brand, +body.dark .sider-brand { + color: rgba(255, 255, 255, 0.92); +} + +html[data-theme='ultra-dark'] .drawer-brand, +html[data-theme='ultra-dark'] .sider-brand { + color: #ffffff; +} + +body.dark .drawer-close { + color: rgba(255, 255, 255, 0.75); +} + +html[data-theme='ultra-dark'] .drawer-close { + color: rgba(255, 255, 255, 0.85); +} + +body.dark .sidebar-theme-cycle { + color: rgba(255, 255, 255, 0.85); +} + +html[data-theme='ultra-dark'] .sidebar-theme-cycle { + color: rgba(255, 255, 255, 0.92); +} + +body.dark .ant-drawer .ant-drawer-content, +body.dark .ant-drawer .ant-drawer-body { + background: #252526 !important; +} + +html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content, +html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body { + background: #0a0a0a !important; +} + +.sider-nav .ant-menu-item-selected, +.sider-utility .ant-menu-item-selected, +.drawer-menu .ant-menu-item-selected { + background-color: rgba(64, 150, 255, 0.2) !important; + color: #4096ff !important; +} + +.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected), +.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected), +.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected), +.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover, +.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover, +.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover { + background-color: rgba(64, 150, 255, 0.1) !important; + color: #4096ff !important; +} diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx new file mode 100644 index 00000000..35983ee7 --- /dev/null +++ b/frontend/src/components/AppSidebar.tsx @@ -0,0 +1,260 @@ +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 = { + 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 ( + + ); +} + +export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) { + const { t } = useTranslation(); + const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); + + const [collapsed, setCollapsed] = useState(() => 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: , + 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>(({ 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 ( +
+ +
+ {collapsed ? '3X' : '3X-UI'} + {!collapsed && ( + cycleTheme('theme-cycle')} + ariaLabel={t('menu.theme')} + /> + )} +
+ + + + + setDrawerOpen(false)} + > +
+ 3X-UI +
+ cycleTheme('theme-cycle-drawer')} + ariaLabel={t('menu.theme')} + /> + +
+
+ { onMenuClick(info); setDrawerOpen(false); }} + /> + { onMenuClick(info); setDrawerOpen(false); }} + /> + + + {!drawerOpen && ( + + )} +
+ ); +} diff --git a/frontend/src/entries/api-docs.js b/frontend/src/entries/api-docs.js deleted file mode 100644 index c2baff6c..00000000 --- a/frontend/src/entries/api-docs.js +++ /dev/null @@ -1,21 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import { applyDocumentTitle } from '@/utils'; -import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/api-docs.tsx b/frontend/src/entries/api-docs.tsx new file mode 100644 index 00000000..9a32c5cd --- /dev/null +++ b/frontend/src/entries/api-docs.tsx @@ -0,0 +1,28 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import { applyDocumentTitle } from '@/utils'; +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import ApiDocsPage from '@/pages/api-docs/ApiDocsPage'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/pages/api-docs/ApiDocsPage.css b/frontend/src/pages/api-docs/ApiDocsPage.css new file mode 100644 index 00000000..5b94cd9f --- /dev/null +++ b/frontend/src/pages/api-docs/ApiDocsPage.css @@ -0,0 +1,292 @@ +.api-docs-page { + --bg-page: #e6e8ec; + --bg-card: #ffffff; + min-height: 100vh; + background: var(--bg-page); +} + +.api-docs-page.is-dark { + --bg-page: #1e1e1e; + --bg-card: #252526; +} + +.api-docs-page.is-dark.is-ultra { + --bg-page: #000; + --bg-card: #0a0a0a; +} + +.api-docs-page .content-shell { + background: var(--bg-page); +} + +.api-docs-page .content-area { + padding: 24px; + max-width: 100%; +} + +@media (max-width: 768px) { + .api-docs-page .content-area { + padding: 16px 12px 12px; + padding-top: 64px; + } +} + +.docs-wrapper { + max-width: 1100px; + margin: 0 auto; +} + +.docs-header { + margin-bottom: 20px; + padding: 24px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 10px; +} + +.docs-title { + font-size: 28px; + font-weight: 800; + margin: 0 0 8px; + color: rgba(0, 0, 0, 0.88); + letter-spacing: -0.3px; +} + +.docs-lead { + margin: 0; + color: rgba(0, 0, 0, 0.65); + line-height: 1.65; + font-size: 14px; +} + +.docs-lead code, +.token-hint code { + background: rgba(128, 128, 128, 0.12); + padding: 1px 6px; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; +} + +.token-card, +.curl-card { + margin-bottom: 16px; +} + +.token-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; + min-height: 32px; +} + +.token-card-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; +} + +.token-hint { + margin: 10px 0 0; + color: rgba(0, 0, 0, 0.55); + font-size: 12.5px; + line-height: 1.55; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.search-bar { + flex: 1; + min-width: 200px; + max-width: 480px; +} + +.match-count { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; +} + +.toc-nav { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px 12px; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 8px; + margin-bottom: 16px; +} + +.toc-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: rgba(0, 0, 0, 0.5); + padding-top: 3px; + flex-shrink: 0; +} + +.toc-links { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.toc-link { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + font-size: 12.5px; + color: rgba(0, 0, 0, 0.65); + background: rgba(128, 128, 128, 0.06); + border: 1px solid transparent; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.toc-link:hover { + background: rgba(22, 119, 255, 0.08); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.2); +} + +.toc-link.active { + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.3); + font-weight: 600; +} + +.toc-icon { + font-size: 13px; + opacity: 0.8; +} + +.toc-text { + font-size: 12.5px; +} + +.toc-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 10.5px; + font-weight: 700; + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + line-height: 1; +} + +.toc-link.active .toc-badge { + background: #1677ff; + color: #fff; +} + +body.dark .docs-title { + color: rgba(255, 255, 255, 0.92); +} + +html[data-theme='ultra-dark'] .docs-title { + color: rgba(255, 255, 255, 0.95); +} + +body.dark .docs-header { + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .docs-header { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); +} + +body.dark .docs-lead, +body.dark .token-hint { + color: rgba(255, 255, 255, 0.7); +} + +html[data-theme='ultra-dark'] .docs-lead, +html[data-theme='ultra-dark'] .token-hint { + color: rgba(255, 255, 255, 0.75); +} + +body.dark .docs-lead code, +body.dark .token-hint code { + background: rgba(255, 255, 255, 0.1); +} + +html[data-theme='ultra-dark'] .docs-lead code, +html[data-theme='ultra-dark'] .token-hint code { + background: rgba(255, 255, 255, 0.12); +} + +body.dark .toc-nav { + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .toc-nav { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); +} + +body.dark .toc-label { + color: rgba(255, 255, 255, 0.55); +} + +html[data-theme='ultra-dark'] .toc-label { + color: rgba(255, 255, 255, 0.6); +} + +body.dark .toc-link { + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.06); +} + +html[data-theme='ultra-dark'] .toc-link { + background: rgba(255, 255, 255, 0.04); +} + +body.dark .toc-link:hover { + background: rgba(88, 166, 255, 0.12); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.25); +} + +body.dark .toc-link.active { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.35); +} + +body.dark .toc-badge { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; +} + +body.dark .toc-link.active .toc-badge { + background: #58a6ff; + color: #0d1117; +} diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx new file mode 100644 index 00000000..7581dbbc --- /dev/null +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ComponentType, MouseEvent } from 'react'; +import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd'; +import { + ApiOutlined, + CloudServerOutlined, + ClusterOutlined, + CompressOutlined, + ExpandOutlined, + GlobalOutlined, + KeyOutlined, + LinkOutlined, + NodeIndexOutlined, + SafetyCertificateOutlined, + SaveOutlined, + SearchOutlined, + SettingOutlined, + WifiOutlined, +} from '@ant-design/icons'; + +import { useTheme } from '@/hooks/useTheme'; +import AppSidebar from '@/components/AppSidebar'; +import { sections as allSections } from './endpoints.js'; +import EndpointSection from './EndpointSection'; +import type { Section } from './EndpointSection'; +import CodeBlock from './CodeBlock'; +import './ApiDocsPage.css'; + +const sectionIcons: Record> = { + authentication: SafetyCertificateOutlined, + inbounds: NodeIndexOutlined, + server: CloudServerOutlined, + nodes: ClusterOutlined, + 'custom-geo': GlobalOutlined, + backup: SaveOutlined, + settings: SettingOutlined, + 'api-tokens': KeyOutlined, + 'xray-settings': WifiOutlined, + subscription: LinkOutlined, + websocket: ApiOutlined, +}; + +const curlExample = `curl -X GET \\ + -H "Authorization: Bearer YOUR_API_TOKEN" \\ + -H "Accept: application/json" \\ + https://your-panel.example.com/panel/api/inbounds/list`; + +const basePath = window.X_UI_BASE_PATH || ''; +const requestUri = window.location.pathname; +const settingsHref = `${basePath}panel/settings#security`; + +const endpointCount = (allSections as Section[]).reduce( + (sum, s) => sum + s.endpoints.length, + 0, +); + +export default function ApiDocsPage() { + const { isDark, isUltra, antdThemeConfig } = useTheme(); + + const [searchQuery, setSearchQuery] = useState(''); + const [collapsedSections, setCollapsedSections] = useState>(() => new Set()); + const [activeSection, setActiveSection] = useState(''); + + const sections = useMemo(() => { + const q = searchQuery.toLowerCase().trim(); + if (!q) return allSections as Section[]; + return (allSections as Section[]) + .map((s) => ({ + ...s, + endpoints: s.endpoints.filter((e) => + e.path.toLowerCase().includes(q) + || e.summary?.toLowerCase().includes(q) + || e.method.toLowerCase().includes(q), + ), + })) + .filter((s) => s.endpoints.length > 0); + }, [searchQuery]); + + const visibleEndpoints = useMemo( + () => sections.reduce((sum, s) => sum + s.endpoints.length, 0), + [sections], + ); + + const toggleSection = useCallback((id: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const expandAll = useCallback(() => setCollapsedSections(new Set()), []); + const collapseAll = useCallback( + () => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))), + [], + ); + + const scrollToSection = useCallback((id: string) => (e: MouseEvent) => { + e.preventDefault(); + const el = document.getElementById(id); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (window.location.hash !== `#${id}`) { + history.replaceState(null, '', `#${id}`); + } + }, []); + + useEffect(() => { + const onHashChange = () => { + const id = window.location.hash.slice(1); + if (!id) return; + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' }); + }; + requestAnimationFrame(onHashChange); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + useEffect(() => { + const onScroll = () => { + const toc = document.querySelector('.toc-nav'); + const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56; + let current = ''; + for (const s of sections) { + const el = document.getElementById(s.id); + if (!el) continue; + const rect = el.getBoundingClientRect(); + if (rect.top <= tocHeight + 20) { + current = s.id; + } + } + setActiveSection(current); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + requestAnimationFrame(onScroll); + return () => window.removeEventListener('scroll', onScroll); + }, [sections]); + + const pageClass = useMemo(() => { + const classes = ['api-docs-page']; + if (isDark) classes.push('is-dark'); + if (isUltra) classes.push('is-ultra'); + return classes.join(' '); + }, [isDark, isUltra]); + + return ( + + + + + + +
+
+

API Documentation

+

+ The 3x-ui panel exposes a REST API under /panel/api/. Authenticate with the panel session + cookie, or with the Authorization: Bearer <token> header below. Every endpoint + returns a uniform {'{ success, msg, obj }'} envelope unless otherwise noted. +

+
+ + +
+
+ + API Tokens +
+ +
+

+ Create, enable, or revoke named Bearer tokens in{' '} + Settings → Security. Send each request as{' '} + Authorization: Bearer <token>. Token-authenticated callers skip CSRF and don't + need a session cookie. Deleting a token revokes it immediately — running bots will need a new one. +

+
+ + + + + +
+ } + placeholder="Search endpoints by path, method, or description…" + allowClear + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + {visibleEndpoints} / {endpointCount} endpoints + + )} + + + + +
+ + + + {sections.map((s) => ( + toggleSection(s.id)} + /> + ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/api-docs/ApiDocsPage.vue b/frontend/src/pages/api-docs/ApiDocsPage.vue deleted file mode 100644 index 70a31ebc..00000000 --- a/frontend/src/pages/api-docs/ApiDocsPage.vue +++ /dev/null @@ -1,561 +0,0 @@ - - - - - - - diff --git a/frontend/src/pages/api-docs/CodeBlock.vue b/frontend/src/pages/api-docs/CodeBlock.css similarity index 54% rename from frontend/src/pages/api-docs/CodeBlock.vue rename to frontend/src/pages/api-docs/CodeBlock.css index 446016c7..5cf64ceb 100644 --- a/frontend/src/pages/api-docs/CodeBlock.vue +++ b/frontend/src/pages/api-docs/CodeBlock.css @@ -1,67 +1,3 @@ - - - - - - diff --git a/frontend/src/pages/api-docs/CodeBlock.tsx b/frontend/src/pages/api-docs/CodeBlock.tsx new file mode 100644 index 00000000..b4469522 --- /dev/null +++ b/frontend/src/pages/api-docs/CodeBlock.tsx @@ -0,0 +1,66 @@ +import { useMemo, useState } from 'react'; +import { message } from 'antd'; +import { CheckOutlined, CopyOutlined } from '@ant-design/icons'; +import './CodeBlock.css'; + +interface CodeBlockProps { + code?: string; + lang?: string; +} + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +function highlightJson(str: string): string { + const escaped = escapeHtml(str); + return escaped.replace( + /("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g, + (_m, key, colon, string, number, bool, nil) => { + if (colon) return `${key}${colon}`; + if (string) return `${string}`; + if (number) return `${number}`; + if (bool) return `${bool}`; + if (nil) return `${nil}`; + return _m; + }, + ); +} + +export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + const highlighted = useMemo( + () => (lang === 'json' ? highlightJson(code) : escapeHtml(code)), + [code, lang], + ); + + async function copyCode() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + message.success('Copied'); + window.setTimeout(() => setCopied(false), 2000); + } catch { + message.error('Copy failed'); + } + } + + return ( +
+
+ {lang.toUpperCase()} + +
+
+        
+      
+
+ ); +} diff --git a/frontend/src/pages/api-docs/EndpointRow.css b/frontend/src/pages/api-docs/EndpointRow.css new file mode 100644 index 00000000..43cd8c3f --- /dev/null +++ b/frontend/src/pages/api-docs/EndpointRow.css @@ -0,0 +1,93 @@ +.endpoint-row { + padding: 14px 8px; + margin: 0 -8px; + transition: background 0.15s; + border-radius: 6px; +} + +.endpoint-row:hover { + background: rgba(128, 128, 128, 0.03); +} + +.endpoint-row + .endpoint-row { + border-top: 1px solid rgba(128, 128, 128, 0.1); +} + +.endpoint-header { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.method-tag { + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + letter-spacing: 0.5px; + min-width: 56px; + text-align: center; + text-transform: uppercase; + border-radius: 4px; + padding: 2px 8px; + line-height: 1.6; +} + +.endpoint-path { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13.5px; + word-break: break-all; + color: rgba(0, 0, 0, 0.8); + background: rgba(128, 128, 128, 0.06); + padding: 2px 8px; + border-radius: 4px; +} + +.endpoint-summary { + margin: 8px 0 0; + color: rgba(0, 0, 0, 0.6); + line-height: 1.6; + font-size: 13.5px; +} + +.endpoint-block { + margin-top: 14px; +} + +.block-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: rgba(0, 0, 0, 0.45); + margin-bottom: 6px; +} + +.error-label { + color: #cf222e; +} + +body.dark .endpoint-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +body.dark .endpoint-row + .endpoint-row { + border-top-color: rgba(255, 255, 255, 0.08); +} + +body.dark .endpoint-path { + color: rgba(255, 255, 255, 0.82); + background: rgba(255, 255, 255, 0.05); +} + +body.dark .endpoint-summary { + color: rgba(255, 255, 255, 0.65); +} + +body.dark .block-label { + color: rgba(255, 255, 255, 0.45); +} + +body.dark .error-label { + color: #ff7b72; +} diff --git a/frontend/src/pages/api-docs/EndpointRow.tsx b/frontend/src/pages/api-docs/EndpointRow.tsx new file mode 100644 index 00000000..d7fd7ad2 --- /dev/null +++ b/frontend/src/pages/api-docs/EndpointRow.tsx @@ -0,0 +1,84 @@ +import { Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { methodColors, safeInlineHtml } from './endpoints.js'; +import CodeBlock from './CodeBlock'; +import './EndpointRow.css'; + +interface Param { + name: string; + in?: string; + type?: string; + desc?: string; +} + +export interface Endpoint { + method: string; + path: string; + summary?: string; + params?: Param[]; + body?: string; + response?: string; + errorResponse?: string; +} + +const paramColumns: ColumnsType = [ + { title: 'Name', dataIndex: 'name', key: 'name', width: 180 }, + { title: 'In', dataIndex: 'in', key: 'in', width: 100 }, + { title: 'Type', dataIndex: 'type', key: 'type', width: 120 }, + { title: 'Description', dataIndex: 'desc', key: 'desc' }, +]; + +export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) { + const tagColor = (methodColors as Record)[endpoint.method] || 'default'; + const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0; + + return ( +
+
+ {endpoint.method} + {endpoint.path} +
+ + {endpoint.summary && ( +

+ )} + + {hasParams && ( +

+
Parameters
+ + + )} + + {endpoint.body && ( +
+
Request body
+ +
+ )} + + {endpoint.response && ( +
+
Response
+ +
+ )} + + {endpoint.errorResponse && ( +
+
Error response
+ +
+ )} + + ); +} diff --git a/frontend/src/pages/api-docs/EndpointRow.vue b/frontend/src/pages/api-docs/EndpointRow.vue deleted file mode 100644 index 5a811427..00000000 --- a/frontend/src/pages/api-docs/EndpointRow.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - diff --git a/frontend/src/pages/api-docs/EndpointSection.vue b/frontend/src/pages/api-docs/EndpointSection.css similarity index 51% rename from frontend/src/pages/api-docs/EndpointSection.vue rename to frontend/src/pages/api-docs/EndpointSection.css index 795e9bb6..f6a54568 100644 --- a/frontend/src/pages/api-docs/EndpointSection.vue +++ b/frontend/src/pages/api-docs/EndpointSection.css @@ -1,63 +1,3 @@ - - - - - - diff --git a/frontend/src/pages/api-docs/EndpointSection.tsx b/frontend/src/pages/api-docs/EndpointSection.tsx new file mode 100644 index 00000000..8b254d19 --- /dev/null +++ b/frontend/src/pages/api-docs/EndpointSection.tsx @@ -0,0 +1,90 @@ +import type { ComponentType } from 'react'; +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { DownOutlined, RightOutlined } from '@ant-design/icons'; +import EndpointRow from './EndpointRow'; +import type { Endpoint } from './EndpointRow'; +import { safeInlineHtml } from './endpoints.js'; +import './EndpointSection.css'; + +interface SubHeader { + name: string; + desc?: string; +} + +export interface Section { + id: string; + title: string; + description?: string; + endpoints: Endpoint[]; + subHeader?: SubHeader[]; +} + +interface EndpointSectionProps { + section: Section; + icon?: ComponentType<{ className?: string }> | null; + collapsed?: boolean; + onToggle?: () => void; +} + +const subHeaderColumns: ColumnsType = [ + { title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, + { + title: 'Description', + dataIndex: 'desc', + key: 'desc', + render: (value: string) => ( + + ), + }, +]; + +export default function EndpointSection({ + section, + icon: Icon = null, + collapsed = false, + onToggle, +}: EndpointSectionProps) { + const endpointLabel = section.endpoints.length === 1 + ? '1 endpoint' + : `${section.endpoints.length} endpoints`; + + return ( +
+
+
+ {collapsed ? : } + {Icon && } +

{section.title}

+
+ {endpointLabel} +
+ + {section.description && !collapsed && ( +

+ )} + + {section.subHeader && !collapsed && ( +

+
Response headers
+
+ + )} + +
+ {section.endpoints.map((endpoint, idx) => ( + + ))} +
+ + ); +}