diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c86c5a73..0b30aedfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 6ddf48e4a..cf0ac2108 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 `
` stays + issue keys resolved via `t()`, never inline `z.string()`. A whole-form + `zodResolver` is only for a form that submits one schema. AntD `` 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//.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. diff --git a/frontend/README.md b/frontend/README.md index 30aaba18b..77e32aee6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 `` — - 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 `` only as the layout: ```tsx -const [form] = Form.useForm(); +const form = useForm({ defaultValues: initialValues }); -const onFinish = async () => { - const values = await form.validateFields(); - await createInbound.mutateAsync(values); -}; +const onSubmit = (values: InboundFormValues) => createInbound.mutateAsync(values); - - - - - + +
+ + + +
+
``` +`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` for form references, no inline `z.string().min(1)` in rules. Conditional fields use `` with the diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index c9c271034..ca8505520 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -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 = { + '/': ['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: , - }, - { - path: '/inbounds', - title: t('menu.inbounds'), - keywords: [ - 'inbounds', - 'ports', - 'vless', - 'vmess', - 'reality', - 'trojan', - 'shadowsocks', - 'wireguard', - 'hysteria', - 'masque', - ], - icon: , - }, - { - path: '/clients', - title: t('menu.clients'), - keywords: ['clients', 'users', 'sub', 'traffic', 'quota'], - icon: , - }, - { - path: '/groups', - title: t('menu.groups'), - keywords: ['groups', 'tags', 'batch'], - icon: , - }, - { - path: '/nodes', - title: t('menu.nodes'), - keywords: ['nodes', 'servers', 'cluster', 'remote nodes'], - icon: , - }, - { - path: '/hosts', - title: t('menu.hosts'), - keywords: ['hosts', 'sni', 'domains'], - icon: , - }, - { - path: '/outbound', - title: t('menu.outbounds'), - keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'], - icon: , - }, - { - path: '/routing', - title: t('menu.routing'), - keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'], - icon: , - }, - { - path: '/settings', - title: t('menu.settings'), - keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'], - icon: , - }, - { - path: '/xray', - title: t('menu.xray'), - keywords: ['xray', 'templates', 'balancer', 'dns'], - icon: , - }, - { - path: '/api-docs', - title: t('menu.apiDocs'), - keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'], - icon: , - }, - { - path: '/sponsors', - title: t('menu.sponsors'), - keywords: ['sponsors', 'sponsor', 'partners'], - icon: , - }, - ]; + const pages = PANEL_ROUTES.map((route) => ({ + path: route.path, + title: t(route.titleKey), + keywords: PALETTE_KEYWORDS[route.path] ?? [], + icon: , + })); pages .filter((p) => matches(p.title, undefined, p.keywords)) diff --git a/frontend/src/components/form/rhf/FormField.stories.tsx b/frontend/src/components/form/rhf/FormField.stories.tsx index 3c010267c..37541574b 100644 --- a/frontend/src/components/form/rhf/FormField.stories.tsx +++ b/frontend/src/components/form/rhf/FormField.stories.tsx @@ -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>({ + 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>({ + resolver: zodResolver(TrafficSchema), + defaultValues: { totalBytes: 50 * GB }, + }); + const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' }); return (
@@ -128,7 +132,10 @@ const InboundSchema = z.object({ }); function ValidationDemo() { - const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } }); + const methods = useForm>({ + 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>({ + resolver: zodResolver(RealitySchema), defaultValues: { streamSettings: { realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' }, diff --git a/frontend/src/components/form/rhf/index.ts b/frontend/src/components/form/rhf/index.ts index 75151bc1b..f59bb1a4f 100644 --- a/frontend/src/components/form/rhf/index.ts +++ b/frontend/src/components/form/rhf/index.ts @@ -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'; diff --git a/frontend/src/components/form/rhf/useZodForm.ts b/frontend/src/components/form/rhf/useZodForm.ts deleted file mode 100644 index 62546c786..000000000 --- a/frontend/src/components/form/rhf/useZodForm.ts +++ /dev/null @@ -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( - schema: z.ZodType, - options?: Omit, 'resolver'>, -): UseFormReturn { - const resolver = zodResolver( - schema as z.ZodType, - ) as Resolver; - return useForm({ - mode: 'onSubmit', - reValidateMode: 'onChange', - shouldUnregister: false, - ...options, - resolver, - }); -} diff --git a/frontend/src/hooks/usePageTitle.ts b/frontend/src/hooks/usePageTitle.ts index 4b2341efc..7434eb8b4 100644 --- a/frontend/src/hooks/usePageTitle.ts +++ b/frontend/src/hooks/usePageTitle.ts @@ -2,27 +2,14 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; -const TITLE_KEYS: Record = { - '/': '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; diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index bcc84ffba..e86cc260d 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -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 = { - 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 ( ( + 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>(() => { const children: NonNullable = [ @@ -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: , label: tab.title, children: settingsChildren }; + return { + key: tab.key, + icon: , + label: tab.title, + children: settingsChildren, + }; } if (tab.key === '/xray') { - return { key: tab.key, icon: , label: tab.title, children: xrayChildren }; + return { + key: tab.key, + icon: , + label: tab.title, + children: xrayChildren, + }; } - return { key: tab.key, icon: , label: tab.title, title: '' }; + return { key: tab.key, icon: , label: tab.title, title: '' }; }), [settingsChildren, xrayChildren], ); diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index e5927ce4e..6f5035b76 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -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: , - children: [ - { index: true, element: withSuspense() }, - { path: 'inbounds', element: withSuspense() }, - { path: 'clients', element: withSuspense() }, - { path: 'groups', element: withSuspense() }, - { path: 'nodes', element: withSuspense() }, - { path: 'hosts', element: withSuspense() }, - { path: 'settings', element: withSuspense() }, - { path: 'xray', element: withSuspense() }, - { path: 'outbound', element: withSuspense() }, - { path: 'routing', element: withSuspense() }, - { path: 'api-docs', element: withSuspense() }, - { path: 'sponsors', element: withSuspense() }, - ], + children: PANEL_ROUTES.map((route) => { + const element = withSuspense(); + return route.path === '/' ? { index: true, element } : { path: route.path.slice(1), element }; + }), }, ]; diff --git a/frontend/src/routes/panelRoutes.tsx b/frontend/src/routes/panelRoutes.tsx new file mode 100644 index 000000000..b003a11df --- /dev/null +++ b/frontend/src/routes/panelRoutes.tsx @@ -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 = Object.fromEntries( + PANEL_ROUTES.map((route) => [route.path, route.titleKey]), +); diff --git a/frontend/src/test/form-field-hint.test.tsx b/frontend/src/test/form-field-hint.test.tsx index a8ce24aaa..b7b7cb11a 100644 --- a/frontend/src/test/form-field-hint.test.tsx +++ b/frontend/src/test/form-field-hint.test.tsx @@ -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>({ + resolver: zodResolver(Schema), + defaultValues: { totalGB: 0 }, + }); return ( diff --git a/frontend/src/test/navigation-contract.test.tsx b/frontend/src/test/navigation-contract.test.tsx new file mode 100644 index 000000000..11f75311b --- /dev/null +++ b/frontend/src/test/navigation-contract.test.tsx @@ -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, 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, `${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()); + }); +}); diff --git a/frontend/src/test/rhf-form-field.test.tsx b/frontend/src/test/rhf-form-field.test.tsx index 9f9f09e79..706583188 100644 --- a/frontend/src/test/rhf-form-field.test.tsx +++ b/frontend/src/test/rhf-form-field.test.tsx @@ -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; function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) { - const methods = useZodForm(Schema, { + const methods = useForm({ + resolver: zodResolver(Schema), defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB }, }); return (