feat(api-docs): split the API docs page into tabs

The page stacked the WebSocket event cards above every Panel API operation
in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in
separate tabs, and the Panel API shows one OpenAPI tag at a time through
section tabs placed between the Authorize bar and the operations.

The section tabs replace Swagger UI's FilterContainer and wrap the
taggedOperations selector, so all sections share one Swagger instance and
keep authorization and try-it-out state. Swagger's own filter matches tags
by substring ("Settings" would also show "Xray Settings") and does nothing
until set, so the wrapper matches the exact tag and defaults to the first.
Tag names come from the loaded spec rather than importing endpoints.ts,
which would have grown the page chunk from 23 kB to 119 kB.
This commit is contained in:
Sanaei
2026-09-15 23:01:15 +02:00
parent c9e62451e6
commit 01ce2bcecb
2 changed files with 106 additions and 34 deletions
+2 -3
View File
@@ -45,15 +45,14 @@
}
.api-docs-page .websocket-events {
margin-bottom: 16px;
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
}
.api-docs-page .websocket-events h2 {
margin-top: 0;
.api-docs-page .swagger-ui .section-tabs {
margin-top: 20px;
}
.api-docs-page .websocket-events pre {
+104 -31
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`;
const websocketEvents = buildWebSocketEvents(EXAMPLES);
interface TaggedOperations {
keySeq: () => { first: () => string | undefined };
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
}
interface LayoutSelectors {
currentFilter: () => string | false;
}
interface SectionTabsProps {
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
layoutSelectors: LayoutSelectors;
layoutActions: { updateFilter: (tag: string) => void };
}
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
const tags = specSelectors
.tags()
.toJS()
.map((tag) => tag.name);
return (
<div className="wrapper section-tabs">
<Tabs
size="small"
activeKey={layoutSelectors.currentFilter() || tags[0]}
onChange={layoutActions.updateFilter}
items={tags.map((tag) => ({ key: tag, label: tag }))}
/>
</div>
);
}
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
const sectionTabsPlugin = {
statePlugins: {
spec: {
wrapSelectors: {
taggedOperations:
(
select: (...args: unknown[]) => TaggedOperations,
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
) =>
(...args: unknown[]) => {
const operations = select(...args);
const active =
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
return operations.filter((_, tag) => tag === active);
},
},
},
},
components: { FilterContainer: SectionTabs },
};
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<section className="websocket-events" aria-labelledby="websocket-events-title">
<Typography.Title id="websocket-events-title" level={2}>
WebSocket events
</Typography.Title>
<Typography.Paragraph>
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
upgrade, every server message uses{' '}
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
is Unix milliseconds.
</Typography.Paragraph>
<Row gutter={[12, 12]}>
{websocketEvents.map((event) => (
<Col key={event.type} xs={24} sm={12} xl={8}>
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
<pre>{JSON.stringify(event.example, null, 2)}</pre>
</Card>
</Col>
))}
</Row>
</section>
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
tryItOutEnabled
persistAuthorization
/>
</div>
<Tabs
items={[
{
key: 'panel-api',
label: '3X-UI Panel API',
children: (
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
plugins={[sectionTabsPlugin]}
tryItOutEnabled
persistAuthorization
/>
</div>
),
},
{
key: 'websocket-events',
label: 'WebSocket events',
children: (
<section className="websocket-events">
<Typography.Paragraph>
After the cookie-authenticated{' '}
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
message uses{' '}
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
time value is Unix milliseconds.
</Typography.Paragraph>
<Row gutter={[12, 12]}>
{websocketEvents.map((event) => (
<Col key={event.type} xs={24} sm={12} xl={8}>
<Card
size="small"
title={<Typography.Text code>{event.type}</Typography.Text>}
>
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
<pre>{JSON.stringify(event.example, null, 2)}</pre>
</Card>
</Col>
))}
</Row>
</section>
),
},
]}
/>
</Layout.Content>
</Layout>
</Layout>