mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
refactor(frontend): read the panel's navigation from one route manifest (#6922)
The router, the sidebar, the page titles and the command palette each kept their own list of the panel's pages, so a page added to one could go missing from another without any test noticing. They all read src/routes/panelRoutes.tsx now, and a contract test holds the manifest to the router, the translation bundle and the palette keywords. The unused useZodForm helper goes, and CONTRIBUTING, frontend/CLAUDE.md and the frontend README describe the useForm + rhfZodValidate pattern the panel's forms actually use.
This commit is contained in:
+1
-1
@@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
|
||||
- **Function components + hooks** everywhere. No class components.
|
||||
- **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `<!-- ... -->` is fine for template structure.
|
||||
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` rather than inline `z.string()` in rules.
|
||||
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||
|
||||
+9
-6
@@ -33,11 +33,12 @@ The `@` import alias maps to `src/`.
|
||||
- Function components + hooks only; no class components.
|
||||
- Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
|
||||
*why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
|
||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
|
||||
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
|
||||
validate through the `zodResolver` or per-field
|
||||
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with React Hook
|
||||
Form's `useForm` + `FormField` from `@/components/form/rhf` (wrap the tree in
|
||||
`FormProvider`) and validate each field with
|
||||
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
|
||||
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
|
||||
issue keys resolved via `t()`, never inline `z.string()`. A whole-form
|
||||
`zodResolver` is only for a form that submits one schema. AntD `<Form>` stays
|
||||
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
|
||||
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
|
||||
`src/lib/xray/forms/fields/`, bound via a `Controller`.
|
||||
@@ -54,8 +55,10 @@ The `@` import alias maps to `src/`.
|
||||
|
||||
## Adding a panel route
|
||||
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
|
||||
2. Register in `src/routes.tsx` under `/panel` (lazy import).
|
||||
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
|
||||
2. Add it to `src/routes/panelRoutes.tsx`. The router, the sidebar, the page
|
||||
title and the command palette all read that one list;
|
||||
`src/test/navigation-contract.test.tsx` holds it to the router and to the
|
||||
translation bundle.
|
||||
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
|
||||
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
|
||||
|
||||
|
||||
+23
-20
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
|
||||
| Command | What |
|
||||
|---|---|
|
||||
| `npm run dev` | Vite dev server with API + WS proxy to Go |
|
||||
| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
|
||||
| `npm run build` | Regenerates the OpenAPI spec, then builds into `../internal/web/dist/` |
|
||||
| `npm run preview` | Serve the built bundle locally |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
|
||||
| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
|
||||
@@ -42,7 +42,7 @@ production-style links work without round-tripping through Go.
|
||||
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
|
||||
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
|
||||
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
|
||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
|
||||
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types,schemas,examples}.ts` |
|
||||
|
||||
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
||||
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
||||
@@ -156,9 +156,10 @@ Patterns:
|
||||
security.
|
||||
- **Three validation layers**, non-overlapping:
|
||||
- API boundary: `parseMsg(msg, schema, ctx)` inside TanStack
|
||||
Query `queryFn` — warn-only in prod, throws in dev
|
||||
- Form input: `antdRule(schema.shape.field)` on every `<Form.Item>` —
|
||||
blocks submit + per-field inline error
|
||||
Query `queryFn` — warns on a mismatch and returns the payload; pass
|
||||
`{ strict: true }` to throw instead
|
||||
- Form input: `rules={{ validate: rhfZodValidate(schema.shape.field) }}` on
|
||||
every `FormField` — blocks submit + per-field inline error
|
||||
- Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
|
||||
because a malformed payload here is always a developer bug
|
||||
- **No `.loose()` or `[key: string]: any`** in production schemas.
|
||||
@@ -166,27 +167,29 @@ Patterns:
|
||||
|
||||
## Form pattern (Pattern A)
|
||||
|
||||
All non-trivial modals use this single pattern:
|
||||
All non-trivial modals use this single pattern — React Hook Form for the
|
||||
state, AntD's `<Form>` only as the layout:
|
||||
|
||||
```tsx
|
||||
const [form] = Form.useForm<InboundFormValues>();
|
||||
const form = useForm<InboundFormValues>({ defaultValues: initialValues });
|
||||
|
||||
const onFinish = async () => {
|
||||
const values = await form.validateFields();
|
||||
await createInbound.mutateAsync(values);
|
||||
};
|
||||
const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values);
|
||||
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="Port"
|
||||
rules={[antdRule(InboundFormSchema.shape.port, t)]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<FormProvider {...form}>
|
||||
<Form layout="vertical" onFinish={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="port"
|
||||
label={t('pages.inbounds.port')}
|
||||
rules={{ validate: rhfZodValidate(InboundFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
```
|
||||
|
||||
`FormField` (from `@/components/form/rhf`) bridges a React Hook Form
|
||||
`Controller` to an AntD input and renders the field's error as help text.
|
||||
No `safeParse`-on-submit handlers, no `useRef<any>` for form
|
||||
references, no inline `z.string().min(1)` in rules. Conditional
|
||||
fields use `<Form.Item dependencies={...} shouldUpdate>` with the
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ApartmentOutlined,
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
@@ -13,13 +12,11 @@ import {
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
ExportOutlined,
|
||||
FileTextOutlined,
|
||||
GlobalOutlined,
|
||||
ImportOutlined,
|
||||
LoadingOutlined,
|
||||
MailOutlined,
|
||||
@@ -31,8 +28,6 @@ import {
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
SwapOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
TelegramFilled,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { PANEL_ROUTES } from '@/routes/panelRoutes';
|
||||
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
||||
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
||||
import './CommandPalette.css';
|
||||
@@ -62,6 +58,34 @@ interface PaletteItem {
|
||||
};
|
||||
}
|
||||
|
||||
// The search terms of every panel page, so the palette finds it by what it
|
||||
// does rather than by its title.
|
||||
export const PALETTE_KEYWORDS: Record<string, string[]> = {
|
||||
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||
'/inbounds': [
|
||||
'inbounds',
|
||||
'ports',
|
||||
'vless',
|
||||
'vmess',
|
||||
'reality',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'masque',
|
||||
],
|
||||
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||
'/groups': ['groups', 'tags', 'batch'],
|
||||
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||
'/hosts': ['hosts', 'sni', 'domains'],
|
||||
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||
'/xray': ['xray', 'templates', 'balancer', 'dns'],
|
||||
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
'/sponsors': ['sponsors', 'sponsor', 'partners'],
|
||||
};
|
||||
|
||||
export default function CommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
@@ -343,91 +367,12 @@ export default function CommandPalette() {
|
||||
});
|
||||
});
|
||||
|
||||
const pages = [
|
||||
{
|
||||
path: '/',
|
||||
title: t('menu.dashboard'),
|
||||
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||
icon: <DashboardOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/inbounds',
|
||||
title: t('menu.inbounds'),
|
||||
keywords: [
|
||||
'inbounds',
|
||||
'ports',
|
||||
'vless',
|
||||
'vmess',
|
||||
'reality',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'wireguard',
|
||||
'hysteria',
|
||||
'masque',
|
||||
],
|
||||
icon: <ImportOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
title: t('menu.clients'),
|
||||
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||
icon: <TeamOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/groups',
|
||||
title: t('menu.groups'),
|
||||
keywords: ['groups', 'tags', 'batch'],
|
||||
icon: <TagsOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/nodes',
|
||||
title: t('menu.nodes'),
|
||||
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||
icon: <ClusterOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/hosts',
|
||||
title: t('menu.hosts'),
|
||||
keywords: ['hosts', 'sni', 'domains'],
|
||||
icon: <GlobalOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/outbound',
|
||||
title: t('menu.outbounds'),
|
||||
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||
icon: <ExportOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/routing',
|
||||
title: t('menu.routing'),
|
||||
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||
icon: <SwapOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
title: t('menu.settings'),
|
||||
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||
icon: <SettingOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/xray',
|
||||
title: t('menu.xray'),
|
||||
keywords: ['xray', 'templates', 'balancer', 'dns'],
|
||||
icon: <ToolOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/api-docs',
|
||||
title: t('menu.apiDocs'),
|
||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
icon: <ApiOutlined aria-hidden />,
|
||||
},
|
||||
{
|
||||
path: '/sponsors',
|
||||
title: t('menu.sponsors'),
|
||||
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||
icon: <CrownOutlined aria-hidden />,
|
||||
},
|
||||
];
|
||||
const pages = PANEL_ROUTES.map((route) => ({
|
||||
path: route.path,
|
||||
title: t(route.titleKey),
|
||||
keywords: PALETTE_KEYWORDS[route.path] ?? [],
|
||||
icon: <route.icon aria-hidden />,
|
||||
}));
|
||||
|
||||
pages
|
||||
.filter((p) => matches(p.title, undefined, p.keywords))
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField } from './FormField';
|
||||
import { useZodForm } from './useZodForm';
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
|
||||
});
|
||||
|
||||
function ClientDemo() {
|
||||
const methods = useZodForm(ClientSchema, {
|
||||
const methods = useForm<z.infer<typeof ClientSchema>>({
|
||||
resolver: zodResolver(ClientSchema),
|
||||
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
|
||||
});
|
||||
return (
|
||||
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
|
||||
});
|
||||
|
||||
function TrafficDemo() {
|
||||
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
|
||||
const totalBytes = methods.watch('totalBytes');
|
||||
const methods = useForm<z.infer<typeof TrafficSchema>>({
|
||||
resolver: zodResolver(TrafficSchema),
|
||||
defaultValues: { totalBytes: 50 * GB },
|
||||
});
|
||||
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical" style={{ maxWidth: 360 }}>
|
||||
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
|
||||
});
|
||||
|
||||
function ValidationDemo() {
|
||||
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
|
||||
const methods = useForm<z.infer<typeof InboundSchema>>({
|
||||
resolver: zodResolver(InboundSchema),
|
||||
defaultValues: { remark: '', port: 0 },
|
||||
});
|
||||
useEffect(() => {
|
||||
void methods.trigger();
|
||||
}, [methods]);
|
||||
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
|
||||
});
|
||||
|
||||
function NestedDemo() {
|
||||
const methods = useZodForm(RealitySchema, {
|
||||
const methods = useForm<z.infer<typeof RealitySchema>>({
|
||||
resolver: zodResolver(RealitySchema),
|
||||
defaultValues: {
|
||||
streamSettings: {
|
||||
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { FormField, type FormFieldProps } from './FormField';
|
||||
export { useZodForm } from './useZodForm';
|
||||
export { rhfZodValidate } from './rhfZodValidate';
|
||||
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
|
||||
export { toDotted, type FieldName } from './toDotted';
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export function useZodForm<TFieldValues extends FieldValues>(
|
||||
schema: z.ZodType<TFieldValues>,
|
||||
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
|
||||
): UseFormReturn<TFieldValues> {
|
||||
const resolver = zodResolver(
|
||||
schema as z.ZodType<TFieldValues, TFieldValues>,
|
||||
) as Resolver<TFieldValues>;
|
||||
return useForm<TFieldValues>({
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
shouldUnregister: false,
|
||||
...options,
|
||||
resolver,
|
||||
});
|
||||
}
|
||||
@@ -2,27 +2,14 @@ import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TITLE_KEYS: Record<string, string> = {
|
||||
'/': 'menu.dashboard',
|
||||
'/inbounds': 'menu.inbounds',
|
||||
'/clients': 'menu.clients',
|
||||
'/groups': 'menu.groups',
|
||||
'/nodes': 'menu.nodes',
|
||||
'/hosts': 'menu.hosts',
|
||||
'/settings': 'menu.settings',
|
||||
'/xray': 'menu.xray',
|
||||
'/outbound': 'menu.outbounds',
|
||||
'/routing': 'menu.routing',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
'/sponsors': 'menu.sponsors',
|
||||
};
|
||||
import { PANEL_TITLE_KEYS } from '@/routes/panelRoutes';
|
||||
|
||||
export function usePageTitle() {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const key = TITLE_KEYS[pathname];
|
||||
const key = PANEL_TITLE_KEYS[pathname];
|
||||
const title = key ? t(key) : '3X-UI';
|
||||
const host = window.location.hostname;
|
||||
document.title = host ? `${host} - ${title}` : title;
|
||||
|
||||
@@ -5,21 +5,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu, Tooltip } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ApartmentOutlined,
|
||||
CloseOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
ExportOutlined,
|
||||
GithubOutlined,
|
||||
GlobalOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
MenuOutlined,
|
||||
@@ -32,11 +26,7 @@ import {
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
SwapOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
TelegramFilled,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -44,6 +34,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||
import { PANEL_ROUTES } from '@/routes/panelRoutes';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
@@ -59,37 +50,6 @@ const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
|
||||
|
||||
let hoveredAcrossRemounts = false;
|
||||
|
||||
type IconName =
|
||||
| 'dashboard'
|
||||
| 'inbound'
|
||||
| 'team'
|
||||
| 'groups'
|
||||
| 'setting'
|
||||
| 'tool'
|
||||
| 'cluster'
|
||||
| 'hosts'
|
||||
| 'logout'
|
||||
| 'sponsors'
|
||||
| 'apidocs'
|
||||
| 'outbound'
|
||||
| 'routing';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
inbound: ImportOutlined,
|
||||
team: TeamOutlined,
|
||||
groups: TagsOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
hosts: GlobalOutlined,
|
||||
logout: LogoutOutlined,
|
||||
sponsors: CrownOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
outbound: ExportOutlined,
|
||||
routing: SwapOutlined,
|
||||
};
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
@@ -228,27 +188,20 @@ export default function AppSidebar() {
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(
|
||||
const tabs = useMemo<{ key: string; icon: ComponentType; title: string }[]>(
|
||||
() => [
|
||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
|
||||
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
...PANEL_ROUTES.map((route) => ({
|
||||
key: route.path,
|
||||
icon: route.icon,
|
||||
title: t(route.titleKey),
|
||||
})),
|
||||
{ key: LOGOUT_KEY, icon: LogoutOutlined, title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.key !== LOGOUT_KEY), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.key === LOGOUT_KEY), [tabs]);
|
||||
|
||||
const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => {
|
||||
const children: NonNullable<MenuProps['items']> = [
|
||||
@@ -341,14 +294,24 @@ export default function AppSidebar() {
|
||||
const toMenuItems = useCallback(
|
||||
(items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
const Icon = tab.icon;
|
||||
if (tab.key === '/settings') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
||||
return {
|
||||
key: tab.key,
|
||||
icon: <Icon aria-hidden />,
|
||||
label: tab.title,
|
||||
children: settingsChildren,
|
||||
};
|
||||
}
|
||||
if (tab.key === '/xray') {
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
||||
return {
|
||||
key: tab.key,
|
||||
icon: <Icon aria-hidden />,
|
||||
label: tab.title,
|
||||
children: xrayChildren,
|
||||
};
|
||||
}
|
||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||
return { key: tab.key, icon: <Icon aria-hidden />, label: tab.title, title: '' };
|
||||
}),
|
||||
[settingsChildren, xrayChildren],
|
||||
);
|
||||
|
||||
+7
-27
@@ -1,19 +1,9 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { createBrowserRouter, type RouteObject } from 'react-router';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import PanelLayout from '@/layouts/PanelLayout';
|
||||
|
||||
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
|
||||
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
|
||||
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
|
||||
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
|
||||
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
|
||||
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
|
||||
import { PANEL_ROUTES } from '@/routes/panelRoutes';
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
@@ -36,24 +26,14 @@ function withSuspense(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <PanelLayout />,
|
||||
children: [
|
||||
{ index: true, element: withSuspense(<IndexPage />) },
|
||||
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
|
||||
{ path: 'clients', element: withSuspense(<ClientsPage />) },
|
||||
{ path: 'groups', element: withSuspense(<GroupsPage />) },
|
||||
{ path: 'nodes', element: withSuspense(<NodesPage />) },
|
||||
{ path: 'hosts', element: withSuspense(<HostsPage />) },
|
||||
{ path: 'settings', element: withSuspense(<SettingsPage />) },
|
||||
{ path: 'xray', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||
{ path: 'sponsors', element: withSuspense(<SponsorsPage />) },
|
||||
],
|
||||
children: PANEL_ROUTES.map((route) => {
|
||||
const element = withSuspense(<route.component />);
|
||||
return route.path === '/' ? { index: true, element } : { path: route.path.slice(1), element };
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { lazy } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClusterOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
ExportOutlined,
|
||||
GlobalOutlined,
|
||||
ImportOutlined,
|
||||
SettingOutlined,
|
||||
SwapOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
|
||||
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
|
||||
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
|
||||
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
|
||||
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
|
||||
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
|
||||
|
||||
export interface PanelRoute {
|
||||
path: string;
|
||||
titleKey: string;
|
||||
icon: ComponentType;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
// The router, the sidebar, the page titles and the command palette all read this list, so a
|
||||
// page cannot go missing from one of them.
|
||||
export const PANEL_ROUTES: PanelRoute[] = [
|
||||
{ path: '/', titleKey: 'menu.dashboard', icon: DashboardOutlined, component: IndexPage },
|
||||
{ path: '/inbounds', titleKey: 'menu.inbounds', icon: ImportOutlined, component: InboundsPage },
|
||||
{ path: '/clients', titleKey: 'menu.clients', icon: TeamOutlined, component: ClientsPage },
|
||||
{ path: '/groups', titleKey: 'menu.groups', icon: TagsOutlined, component: GroupsPage },
|
||||
{ path: '/nodes', titleKey: 'menu.nodes', icon: ClusterOutlined, component: NodesPage },
|
||||
{ path: '/hosts', titleKey: 'menu.hosts', icon: GlobalOutlined, component: HostsPage },
|
||||
{ path: '/outbound', titleKey: 'menu.outbounds', icon: ExportOutlined, component: XrayPage },
|
||||
{ path: '/routing', titleKey: 'menu.routing', icon: SwapOutlined, component: XrayPage },
|
||||
{ path: '/settings', titleKey: 'menu.settings', icon: SettingOutlined, component: SettingsPage },
|
||||
{ path: '/xray', titleKey: 'menu.xray', icon: ToolOutlined, component: XrayPage },
|
||||
{ path: '/api-docs', titleKey: 'menu.apiDocs', icon: ApiOutlined, component: ApiDocsPage },
|
||||
{ path: '/sponsors', titleKey: 'menu.sponsors', icon: CrownOutlined, component: SponsorsPage },
|
||||
];
|
||||
|
||||
export const PANEL_ROUTE_PATHS = PANEL_ROUTES.map((route) => route.path);
|
||||
|
||||
export const PANEL_TITLE_KEYS: Record<string, string> = Object.fromEntries(
|
||||
PANEL_ROUTES.map((route) => [route.path, route.titleKey]),
|
||||
);
|
||||
@@ -1,17 +1,21 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form, InputNumber } from 'antd';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField, useZodForm } from '@/components/form/rhf';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const Schema = z.object({ totalGB: z.number() });
|
||||
|
||||
function Harness() {
|
||||
const methods = useZodForm(Schema, { defaultValues: { totalGB: 0 } });
|
||||
const methods = useForm<z.infer<typeof Schema>>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { totalGB: 0 },
|
||||
});
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PALETTE_KEYWORDS } from '@/components/command-palette/CommandPalette';
|
||||
import { PANEL_ROUTES, PANEL_ROUTE_PATHS } from '@/routes/panelRoutes';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
// Four hand-kept nav lists let a page go missing from the sidebar or fall back to the "3X-UI"
|
||||
// title; the manifest replaced them, and these tests hold it to the router and the bundle.
|
||||
|
||||
const translationDir = resolve(process.cwd(), '..', 'internal', 'web', 'translation');
|
||||
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = `${prefix}${k}`;
|
||||
if (v !== null && typeof v === 'object') {
|
||||
keys.push(...flattenKeys(v as Record<string, unknown>, `${key}.`));
|
||||
} else {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
describe('panel navigation', () => {
|
||||
it('lists every route once', () => {
|
||||
expect(new Set(PANEL_ROUTE_PATHS).size).toBe(PANEL_ROUTE_PATHS.length);
|
||||
});
|
||||
|
||||
it('routes every manifest path', () => {
|
||||
const childPaths = (routes[0].children ?? []).map((child) =>
|
||||
child.index ? '/' : `/${child.path ?? ''}`,
|
||||
);
|
||||
expect(childPaths.sort()).toEqual([...PANEL_ROUTE_PATHS].sort());
|
||||
});
|
||||
|
||||
it('translates every title key', () => {
|
||||
const enKeys = new Set(
|
||||
flattenKeys(
|
||||
JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
),
|
||||
);
|
||||
for (const route of PANEL_ROUTES) {
|
||||
expect(enKeys.has(route.titleKey)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every route command-palette keywords', () => {
|
||||
expect(Object.keys(PALETTE_KEYWORDS).sort()).toEqual([...PANEL_ROUTE_PATHS].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Form, Input, InputNumber, Switch } from 'antd';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField, useZodForm } from '@/components/form/rhf';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -18,7 +19,8 @@ const Schema = z.object({
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
|
||||
const methods = useZodForm(Schema, {
|
||||
const methods = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
|
||||
});
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user