fix(frontend): make the panel usable with screen readers (#6907)

* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
This commit is contained in:
Alireza Ahmadi
2026-10-23 18:29:47 +02:00
committed by GitHub
parent 775b2b0b26
commit e2184777a0
156 changed files with 2373 additions and 863 deletions
+25 -2
View File
@@ -9,7 +9,9 @@
"jsx-a11y" "jsx-a11y"
], ],
"jsPlugins": [ "jsPlugins": [
"./tools/oxlint/input-number-guard.mjs" "./tools/oxlint/input-number-guard.mjs",
"./tools/oxlint/control-label-guard.mjs",
"./tools/oxlint/icon-a11y-guard.mjs"
], ],
"categories": { "categories": {
"correctness": "error" "correctness": "error"
@@ -48,7 +50,9 @@
"react-hooks/exhaustive-deps": "error", "react-hooks/exhaustive-deps": "error",
"jsx-a11y/no-autofocus": "off", "jsx-a11y/no-autofocus": "off",
"input-number/no-synthetic-clear": "off", "input-number/no-synthetic-clear": "off",
"jsx-a11y/prefer-tag-over-role": "off" "jsx-a11y/prefer-tag-over-role": "off",
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
}, },
"overrides": [ "overrides": [
{ {
@@ -67,6 +71,25 @@
"rules": { "rules": {
"input-number/no-synthetic-clear": "off" "input-number/no-synthetic-clear": "off"
} }
},
{
"files": [
"src/**/*.tsx"
],
"rules": {
"control-label/has-name": "error",
"antd-icon/explicit-a11y": "error"
}
},
{
"files": [
"src/**/*.stories.tsx",
"src/test/**"
],
"rules": {
"control-label/has-name": "off",
"antd-icon/explicit-a11y": "off"
}
} }
] ]
} }
@@ -44,7 +44,12 @@ export default function ClientHwidListModal({
zIndex={zIndex} zIndex={zIndex}
onCancel={onClose} onCancel={onClose}
footer={[ footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}> <Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={loading}
onClick={onRefresh}
>
{t('refresh')} {t('refresh')}
</Button>, </Button>,
<Popconfirm <Popconfirm
@@ -122,7 +127,7 @@ export default function ClientHwidListModal({
type="text" type="text"
size="small" size="small"
aria-label={t('pages.clients.deleteHwid')} aria-label={t('pages.clients.deleteHwid')}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
loading={deletingId === entry.id} loading={deletingId === entry.id}
/> />
</Popconfirm> </Popconfirm>
@@ -38,12 +38,17 @@ export default function ConfigBlock({
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}> <div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} /> <Button
size="small"
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={copy}
/>
</Tooltip> </Tooltip>
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)} onClick={() => FileManager.downloadTextFile(text, fileName)}
/> />
@@ -56,7 +61,11 @@ export default function ConfigBlock({
content={<QrPanel value={text} remark={qrRemark || label} size={220} />} content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} /> <Button
size="small"
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip> </Tooltip>
</Popover> </Popover>
)} )}
@@ -262,9 +262,9 @@ export default function CommandPalette() {
title: c.email, title: c.email,
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`, subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
icon: isOnline ? ( icon: isOnline ? (
<CheckCircleFilled style={{ color: '#52c41a' }} /> <CheckCircleFilled aria-hidden style={{ color: '#52c41a' }} />
) : ( ) : (
<CloseCircleFilled style={{ color: '#ff4d4f' }} /> <CloseCircleFilled aria-hidden style={{ color: '#ff4d4f' }} />
), ),
action: () => { action: () => {
close(); close();
@@ -274,7 +274,7 @@ export default function CommandPalette() {
c.subId && allSetting.subURI c.subId && allSetting.subURI
? { ? {
label: t('commandPalette.copySubscription'), label: t('commandPalette.copySubscription'),
icon: <CopyOutlined />, icon: <CopyOutlined aria-hidden />,
execute: (e) => { execute: (e) => {
e.stopPropagation(); e.stopPropagation();
copySubscription(c); copySubscription(c);
@@ -331,7 +331,7 @@ export default function CommandPalette() {
category: 'inbounds', category: 'inbounds',
title: ib.remark || ib.tag || `Inbound #${ib.id}`, title: ib.remark || ib.tag || `Inbound #${ib.id}`,
subtitle: `Port ${ib.port || ''}`, subtitle: `Port ${ib.port || ''}`,
icon: <ImportOutlined style={{ color: '#1677ff' }} />, icon: <ImportOutlined aria-hidden style={{ color: '#1677ff' }} />,
tag: tag:
tags.length > 0 ? ( tags.length > 0 ? (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div> <div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
@@ -348,7 +348,7 @@ export default function CommandPalette() {
path: '/', path: '/',
title: t('menu.dashboard'), title: t('menu.dashboard'),
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'], keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
}, },
{ {
path: '/inbounds', path: '/inbounds',
@@ -365,67 +365,67 @@ export default function CommandPalette() {
'hysteria', 'hysteria',
'masque', 'masque',
], ],
icon: <ImportOutlined />, icon: <ImportOutlined aria-hidden />,
}, },
{ {
path: '/clients', path: '/clients',
title: t('menu.clients'), title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'], keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined />, icon: <TeamOutlined aria-hidden />,
}, },
{ {
path: '/groups', path: '/groups',
title: t('menu.groups'), title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'], keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined />, icon: <TagsOutlined aria-hidden />,
}, },
{ {
path: '/nodes', path: '/nodes',
title: t('menu.nodes'), title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'], keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined />, icon: <ClusterOutlined aria-hidden />,
}, },
{ {
path: '/hosts', path: '/hosts',
title: t('menu.hosts'), title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'], keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined />, icon: <GlobalOutlined aria-hidden />,
}, },
{ {
path: '/outbound', path: '/outbound',
title: t('menu.outbounds'), title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'], keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined />, icon: <ExportOutlined aria-hidden />,
}, },
{ {
path: '/routing', path: '/routing',
title: t('menu.routing'), title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'], keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined />, icon: <SwapOutlined aria-hidden />,
}, },
{ {
path: '/settings', path: '/settings',
title: t('menu.settings'), title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'], keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined />, icon: <SettingOutlined aria-hidden />,
}, },
{ {
path: '/xray', path: '/xray',
title: t('menu.xray'), title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'], keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined />, icon: <ToolOutlined aria-hidden />,
}, },
{ {
path: '/api-docs', path: '/api-docs',
title: t('menu.apiDocs'), title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'], keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />, icon: <ApiOutlined aria-hidden />,
}, },
{ {
path: '/sponsors', path: '/sponsors',
title: t('menu.sponsors'), title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'], keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined />, icon: <CrownOutlined aria-hidden />,
}, },
]; ];
@@ -451,63 +451,63 @@ export default function CommandPalette() {
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
subtitle: t('pages.settings.panelSettings'), subtitle: t('pages.settings.panelSettings'),
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'], keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
icon: <SettingOutlined />, icon: <SettingOutlined aria-hidden />,
}, },
{ {
path: '/settings#security', path: '/settings#security',
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
subtitle: t('pages.settings.securitySettings'), subtitle: t('pages.settings.securitySettings'),
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'], keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
}, },
{ {
path: '/settings#telegram', path: '/settings#telegram',
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
subtitle: t('pages.settings.TGBotSettings'), subtitle: t('pages.settings.TGBotSettings'),
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'], keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
icon: <MessageOutlined />, icon: <MessageOutlined aria-hidden />,
}, },
{ {
path: '/settings#email', path: '/settings#email',
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
subtitle: t('pages.settings.emailSettings'), subtitle: t('pages.settings.emailSettings'),
keywords: ['email', 'smtp', 'mail', 'crash alerts'], keywords: ['email', 'smtp', 'mail', 'crash alerts'],
icon: <MailOutlined />, icon: <MailOutlined aria-hidden />,
}, },
{ {
path: '/settings#discord', path: '/settings#discord',
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
subtitle: t('pages.settings.discordSettings'), subtitle: t('pages.settings.discordSettings'),
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'], keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
icon: <DiscordOutlined />, icon: <DiscordOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription', path: '/settings#subscription',
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`, title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
subtitle: t('pages.settings.subSettings'), subtitle: t('pages.settings.subSettings'),
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'], keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription-formats', path: '/settings#subscription-formats',
title: `${t('menu.settings')} · ${t('menu.subFormats')}`, title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
subtitle: t('menu.subFormats'), subtitle: t('menu.subFormats'),
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'], keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
icon: <CodeOutlined />, icon: <CodeOutlined aria-hidden />,
}, },
{ {
path: '/settings#subscription-balancers', path: '/settings#subscription-balancers',
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`, title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
subtitle: t('pages.settings.subBalancers.menu'), subtitle: t('pages.settings.subBalancers.menu'),
keywords: ['balancers', 'sub balancers', 'balancer nodes'], keywords: ['balancers', 'sub balancers', 'balancer nodes'],
icon: <ApartmentOutlined />, icon: <ApartmentOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`, title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
subtitle: t('pages.xray.basicTemplate'), subtitle: t('pages.xray.basicTemplate'),
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'], keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
icon: <ToolOutlined />, icon: <ToolOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
@@ -522,56 +522,56 @@ export default function CommandPalette() {
'statsOutbound', 'statsOutbound',
'metrics_out', 'metrics_out',
], ],
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`, title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
subtitle: t('pages.xray.connectionLimits'), subtitle: t('pages.xray.connectionLimits'),
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'], keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined aria-hidden />,
}, },
{ {
path: '/xray#basic', path: '/xray#basic',
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`, title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
subtitle: t('pages.xray.logConfigs'), subtitle: t('pages.xray.logConfigs'),
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'], keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
icon: <FileTextOutlined />, icon: <FileTextOutlined aria-hidden />,
}, },
{ {
path: '/xray#balancer', path: '/xray#balancer',
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`, title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
subtitle: t('pages.xray.Balancers'), subtitle: t('pages.xray.Balancers'),
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'], keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
icon: <ClusterOutlined />, icon: <ClusterOutlined aria-hidden />,
}, },
{ {
path: '/xray#dns', path: '/xray#dns',
title: `${t('menu.xray')} · DNS`, title: `${t('menu.xray')} · DNS`,
subtitle: 'DNS', subtitle: 'DNS',
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'], keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
icon: <DatabaseOutlined />, icon: <DatabaseOutlined aria-hidden />,
}, },
{ {
path: '/xray#outbound', path: '/xray#outbound',
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`, title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
subtitle: t('pages.xray.Outbounds'), subtitle: t('pages.xray.Outbounds'),
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'], keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
icon: <ExportOutlined />, icon: <ExportOutlined aria-hidden />,
}, },
{ {
path: '/xray#routing', path: '/xray#routing',
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`, title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
subtitle: t('pages.xray.basicRouting'), subtitle: t('pages.xray.basicRouting'),
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'], keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
icon: <SwapOutlined />, icon: <SwapOutlined aria-hidden />,
}, },
{ {
path: '/xray#advanced', path: '/xray#advanced',
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`, title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
subtitle: t('pages.xray.advancedTemplate'), subtitle: t('pages.xray.advancedTemplate'),
keywords: ['advanced', 'json template', 'advanced config', 'custom json'], keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
icon: <CodeOutlined />, icon: <CodeOutlined aria-hidden />,
}, },
]; ];
@@ -599,7 +599,7 @@ export default function CommandPalette() {
title: t('commandPalette.restartXray'), title: t('commandPalette.restartXray'),
subtitle: t('pages.index.restartXray'), subtitle: t('pages.index.restartXray'),
keywords: ['restart', 'xray restart', 'reboot xray'], keywords: ['restart', 'xray restart', 'reboot xray'],
icon: <ReloadOutlined style={{ color: '#faad14' }} />, icon: <ReloadOutlined aria-hidden style={{ color: '#faad14' }} />,
action: restartXray, action: restartXray,
}, },
{ {
@@ -608,7 +608,7 @@ export default function CommandPalette() {
title: t('menu.theme'), title: t('menu.theme'),
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light', subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
keywords: ['theme', 'light', 'dark', 'ultra'], keywords: ['theme', 'light', 'dark', 'ultra'],
icon: isDark ? <SunOutlined /> : <MoonOutlined />, icon: isDark ? <SunOutlined aria-hidden /> : <MoonOutlined aria-hidden />,
action: cycleTheme, action: cycleTheme,
}, },
{ {
@@ -617,7 +617,7 @@ export default function CommandPalette() {
title: t('pages.inbounds.addInbound'), title: t('pages.inbounds.addInbound'),
subtitle: t('menu.inbounds'), subtitle: t('menu.inbounds'),
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'], keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />, icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => { action: () => {
close(); close();
navigate('/inbounds'); navigate('/inbounds');
@@ -629,7 +629,7 @@ export default function CommandPalette() {
title: t('pages.clients.addClient'), title: t('pages.clients.addClient'),
subtitle: t('menu.clients'), subtitle: t('menu.clients'),
keywords: ['add client', 'create user', 'new client', 'new user'], keywords: ['add client', 'create user', 'new client', 'new user'],
icon: <PlusOutlined style={{ color: '#52c41a' }} />, icon: <PlusOutlined aria-hidden style={{ color: '#52c41a' }} />,
action: () => { action: () => {
close(); close();
navigate('/clients'); navigate('/clients');
@@ -705,9 +705,9 @@ export default function CommandPalette() {
> >
<div className="command-palette-header"> <div className="command-palette-header">
{isClientSearching ? ( {isClientSearching ? (
<LoadingOutlined className="command-palette-search-icon spinning" /> <LoadingOutlined aria-hidden className="command-palette-search-icon spinning" />
) : ( ) : (
<SearchOutlined className="command-palette-search-icon" /> <SearchOutlined aria-hidden className="command-palette-search-icon" />
)} )}
<input <input
ref={inputRef} ref={inputRef}
@@ -69,11 +69,11 @@ export default function TextModal({
footer={ footer={
<> <>
{fileName && ( {fileName && (
<Button icon={<DownloadOutlined />} onClick={download}> <Button icon={<DownloadOutlined aria-hidden />} onClick={download}>
{fileName} {fileName}
</Button> </Button>
)} )}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}> <Button type="primary" icon={<CopyOutlined aria-hidden />} onClick={copy}>
{t('copy')} {t('copy')}
</Button> </Button>
</> </>
@@ -19,6 +19,9 @@ interface DateTimePickerProps {
disabled?: boolean; disabled?: boolean;
allowClear?: boolean; allowClear?: boolean;
maxDate?: Dayjs; maxDate?: Dayjs;
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
} }
const LIGHT_THEME = { const LIGHT_THEME = {
@@ -57,6 +60,9 @@ export default function DateTimePicker({
disabled = false, disabled = false,
allowClear = true, allowClear = true,
maxDate, maxDate,
id,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: DateTimePickerProps) { }: DateTimePickerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
@@ -97,7 +103,11 @@ export default function DateTimePicker({
useEffect(() => { useEffect(() => {
if (datepicker !== 'jalalian') return; if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input'); const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder; if (!input) return;
input.placeholder = placeholder;
if (id) input.id = id;
if (ariaLabel) input.setAttribute('aria-label', ariaLabel);
if (ariaDescribedBy) input.setAttribute('aria-describedby', ariaDescribedBy);
}); });
if (datepicker === 'jalalian') { if (datepicker === 'jalalian') {
@@ -137,7 +147,7 @@ export default function DateTimePicker({
setClearNonce((n) => n + 1); setClearNonce((n) => n + 1);
}} }}
> >
<CloseCircleFilled /> <CloseCircleFilled aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -156,6 +166,9 @@ export default function DateTimePicker({
disabled={disabled} disabled={disabled}
allowClear={allowClear} allowClear={allowClear}
maxDate={maxDate} maxDate={maxDate}
id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
); );
@@ -125,21 +125,23 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
<Input <Input
value={row.name} value={row.name}
placeholder="Name" placeholder="Name"
aria-label={t('pages.inbounds.form.headerName')}
onChange={(e) => setRow(idx, { name: e.target.value })} onChange={(e) => setRow(idx, { name: e.target.value })}
/> />
<Input <Input
value={row.value} value={row.value}
placeholder="Value" placeholder="Value"
aria-label={t('pages.inbounds.form.headerValue')}
onChange={(e) => setRow(idx, { value: e.target.value })} onChange={(e) => setRow(idx, { value: e.target.value })}
/> />
<Button <Button
aria-label={t('remove')} aria-label={t('remove')}
icon={<MinusOutlined />} icon={<MinusOutlined aria-hidden />}
onClick={() => removeRow(idx)} onClick={() => removeRow(idx)}
/> />
</Space.Compact> </Space.Compact>
))} ))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}> <Button size="small" type="primary" icon={<PlusOutlined aria-hidden />} onClick={addRow}>
Add Add
</Button> </Button>
</> </>
@@ -22,6 +22,9 @@ interface RemarkTemplateFieldProps {
multiline?: boolean; multiline?: boolean;
rows?: number; rows?: number;
metadataOnly?: boolean; metadataOnly?: boolean;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
} }
/** /**
@@ -37,6 +40,9 @@ export default function RemarkTemplateField({
multiline = false, multiline = false,
rows, rows,
metadataOnly = false, metadataOnly = false,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: RemarkTemplateFieldProps) { }: RemarkTemplateFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<InputRef>(null); const inputRef = useRef<InputRef>(null);
@@ -76,7 +82,7 @@ export default function RemarkTemplateField({
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<CodeOutlined />} icon={<CodeOutlined aria-hidden />}
aria-label={t('pages.hosts.remarkVars.title')} aria-label={t('pages.hosts.remarkVars.title')}
style={{ marginInlineEnd: -7 }} style={{ marginInlineEnd: -7 }}
/> />
@@ -90,6 +96,9 @@ export default function RemarkTemplateField({
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Input.TextArea <Input.TextArea
ref={textAreaRef} ref={textAreaRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value} value={value}
maxLength={maxLength} maxLength={maxLength}
placeholder={placeholder} placeholder={placeholder}
@@ -101,6 +110,9 @@ export default function RemarkTemplateField({
) : ( ) : (
<Input <Input
ref={inputRef} ref={inputRef}
id={id}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
value={value} value={value}
maxLength={maxLength} maxLength={maxLength}
placeholder={placeholder} placeholder={placeholder}
+14 -2
View File
@@ -49,8 +49,13 @@ export function FormField<T extends FieldValues = FieldValues>({
const { t } = useTranslation(); const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>; const dottedName = toDotted(name) as Path<T>;
const generatedId = useId(); const generatedId = useId();
const explicitId = (children as ReactElement<{ id?: string }>).props.id; const hintId = useId();
const fieldId = explicitId ?? generatedId; const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return ( return (
<Controller <Controller
@@ -74,6 +79,7 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur, onBlur: field.onBlur,
ref: field.ref, ref: field.ref,
}; };
if (describedBy) childProps['aria-describedby'] = describedBy;
return ( return (
<Form.Item <Form.Item
label={label} label={label}
@@ -88,6 +94,12 @@ export function FormField<T extends FieldValues = FieldValues>({
style={style} style={style}
> >
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)} {cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
{/* The tooltip only opens on hover, so screen readers get its text as a description. */}
{tooltip && (
<span id={hintId} hidden>
{tooltip}
</span>
)}
</Form.Item> </Form.Item>
); );
}} }}
@@ -332,6 +332,7 @@ export default function GeoBrowserModal({
value={categoryQuery} value={categoryQuery}
onChange={(event) => setCategoryQuery(event.target.value)} onChange={(event) => setCategoryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchCategory')} placeholder={t('pages.xray.geoBrowser.searchCategory')}
aria-label={t('pages.xray.geoBrowser.searchCategory')}
allowClear allowClear
/> />
<Button <Button
@@ -392,6 +393,7 @@ export default function GeoBrowserModal({
value={entryQuery} value={entryQuery}
onChange={(event) => setEntryQuery(event.target.value)} onChange={(event) => setEntryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchEntries')} placeholder={t('pages.xray.geoBrowser.searchEntries')}
aria-label={t('pages.xray.geoBrowser.searchEntries')}
allowClear allowClear
className="geo-entry-filter" className="geo-entry-filter"
/> />
@@ -30,6 +30,7 @@ export interface GeoTokenInputProps {
kind: GeoTokenKind; kind: GeoTokenKind;
placeholder?: string; placeholder?: string;
id?: string; id?: string;
'aria-describedby'?: string;
ref?: Ref<InputRef>; ref?: Ref<InputRef>;
} }
@@ -40,6 +41,7 @@ export default function GeoTokenInput({
kind, kind,
placeholder, placeholder,
id, id,
'aria-describedby': ariaDescribedBy,
ref, ref,
}: GeoTokenInputProps) { }: GeoTokenInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -92,6 +94,7 @@ export default function GeoTokenInput({
<Input <Input
ref={ref} ref={ref}
id={id} id={id}
aria-describedby={ariaDescribedBy}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
@@ -99,7 +102,7 @@ export default function GeoTokenInput({
/> />
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}> <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
<Button <Button
icon={<DatabaseOutlined />} icon={<DatabaseOutlined aria-hidden />}
aria-label={t('pages.xray.geoBrowser.openTooltip')} aria-label={t('pages.xray.geoBrowser.openTooltip')}
onClick={() => setBrowsing(true)} onClick={() => setBrowsing(true)}
/> />
@@ -63,13 +63,13 @@ export default function SponsorCard({ sponsor, variant = 'banner', onClose }: Sp
<span className="sponsor-aside"> <span className="sponsor-aside">
<span className="sponsor-tag">{tag}</span> <span className="sponsor-tag">{tag}</span>
<span className="sponsor-visit"> <span className="sponsor-visit">
{t('pages.sponsors.visit')} <ExportOutlined /> {t('pages.sponsors.visit')} <ExportOutlined aria-hidden />
</span> </span>
</span> </span>
)} )}
{variant === 'card' && ( {variant === 'card' && (
<span className="sponsor-visit"> <span className="sponsor-visit">
{t('pages.sponsors.visit')} <ExportOutlined /> {t('pages.sponsors.visit')} <ExportOutlined aria-hidden />
</span> </span>
)} )}
</a> </a>
@@ -81,7 +81,7 @@ export default function SponsorCard({ sponsor, variant = 'banner', onClose }: Sp
title={t('close')} title={t('close')}
onClick={onClose} onClick={onClose}
> >
<CloseOutlined /> <CloseOutlined aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [ const GROUPS: NotificationGroupConfig[] = [
{ {
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
title: 'eventGroupOutbound', title: 'eventGroupOutbound',
events: [ events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
@@ -21,12 +21,12 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <ThunderboltOutlined />, icon: <ThunderboltOutlined aria-hidden />,
title: 'eventGroupXray', title: 'eventGroupXray',
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
}, },
{ {
icon: <DesktopOutlined />, icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode', title: 'eventGroupNode',
events: [ events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' }, { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
@@ -34,7 +34,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
title: 'eventGroupSystem', title: 'eventGroupSystem',
events: [ events: [
{ {
@@ -72,7 +72,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
title: 'eventGroupSecurity', title: 'eventGroupSecurity',
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
}, },
@@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [ const GROUPS: NotificationGroupConfig[] = [
{ {
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
title: 'eventGroupOutbound', title: 'eventGroupOutbound',
events: [ events: [
{ {
@@ -36,12 +36,12 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <ThunderboltOutlined />, icon: <ThunderboltOutlined aria-hidden />,
title: 'eventGroupXray', title: 'eventGroupXray',
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
}, },
{ {
icon: <DesktopOutlined />, icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode', title: 'eventGroupNode',
events: [ events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' }, { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
@@ -49,7 +49,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
title: 'eventGroupSystem', title: 'eventGroupSystem',
events: [ events: [
{ {
@@ -87,7 +87,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
title: 'eventGroupSecurity', title: 'eventGroupSecurity',
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
}, },
@@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types';
const GROUPS: NotificationGroupConfig[] = [ const GROUPS: NotificationGroupConfig[] = [
{ {
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
title: 'eventGroupOutbound', title: 'eventGroupOutbound',
events: [ events: [
{ {
@@ -36,12 +36,12 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <ThunderboltOutlined />, icon: <ThunderboltOutlined aria-hidden />,
title: 'eventGroupXray', title: 'eventGroupXray',
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
}, },
{ {
icon: <DesktopOutlined />, icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode', title: 'eventGroupNode',
events: [ events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' }, { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
@@ -49,7 +49,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
title: 'eventGroupSystem', title: 'eventGroupSystem',
events: [ events: [
{ {
@@ -87,7 +87,7 @@ const GROUPS: NotificationGroupConfig[] = [
], ],
}, },
{ {
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
title: 'eventGroupSecurity', title: 'eventGroupSecurity',
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
}, },
+8 -5
View File
@@ -5,6 +5,7 @@ import 'antd/dist/reset.css';
import { setupHttp } from '@/api/http-init'; import { setupHttp } from '@/api/http-init';
import { applyDocumentTitle } from '@/utils'; import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider'; import { QueryProvider } from '@/api/QueryProvider';
import LoginPage from '@/pages/login/LoginPage'; import LoginPage from '@/pages/login/LoginPage';
@@ -21,11 +22,13 @@ readyI18n().then(() => {
const root = document.getElementById('app'); const root = document.getElementById('app');
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <AntdRootConfig>
<QueryProvider> <ThemeProvider>
<LoginPage /> <QueryProvider>
</QueryProvider> <LoginPage />
</ThemeProvider>, </QueryProvider>
</ThemeProvider>
</AntdRootConfig>,
); );
} }
}); });
+8 -5
View File
@@ -3,6 +3,7 @@ import { message } from 'antd';
import 'antd/dist/reset.css'; import 'antd/dist/reset.css';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider'; import { QueryProvider } from '@/api/QueryProvider';
import SubPage from '@/pages/sub/SubPage'; import SubPage from '@/pages/sub/SubPage';
@@ -16,11 +17,13 @@ readyI18n('subscription').then(() => {
const root = document.getElementById('app'); const root = document.getElementById('app');
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <AntdRootConfig>
<QueryProvider> <ThemeProvider>
<SubPage /> <QueryProvider>
</QueryProvider> <SubPage />
</ThemeProvider>, </QueryProvider>
</ThemeProvider>
</AntdRootConfig>,
); );
} }
}); });
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { ConfigProvider } from 'antd';
import type { ConfigProviderProps } from 'antd/es/config-provider';
import type { Locale } from 'antd/es/locale';
const antdLocaleLoaders: Record<string, () => Promise<{ default: Locale }>> = {
'ar-EG': () => import('antd/es/locale/ar_EG'),
'es-ES': () => import('antd/es/locale/es_ES'),
'fa-IR': () => import('antd/es/locale/fa_IR'),
'id-ID': () => import('antd/es/locale/id_ID'),
'ja-JP': () => import('antd/es/locale/ja_JP'),
'pt-BR': () => import('antd/es/locale/pt_BR'),
'ru-RU': () => import('antd/es/locale/ru_RU'),
'tr-TR': () => import('antd/es/locale/tr_TR'),
'uk-UA': () => import('antd/es/locale/uk_UA'),
'vi-VN': () => import('antd/es/locale/vi_VN'),
'zh-CN': () => import('antd/es/locale/zh_CN'),
'zh-TW': () => import('antd/es/locale/zh_TW'),
};
// Undefined keeps antd's built-in en_US.
let antdLocale: Locale | undefined;
export async function loadAntdLocale(language: string): Promise<void> {
antdLocale = (await antdLocaleLoaders[language]?.())?.default;
}
// A Form.Item tooltip icon sits inside its <label>, so its "question-circle" name
// became part of every hinted field's name. FormField exposes the hint as a description.
const FORM_CONFIG: ConfigProviderProps['form'] = {
tooltip: { icon: <QuestionCircleOutlined aria-hidden /> },
};
export function AntdRootConfig({ children }: { children: ReactNode }) {
return (
<ConfigProvider locale={antdLocale} form={FORM_CONFIG}>
{children}
</ConfigProvider>
);
}
+4
View File
@@ -3,6 +3,7 @@ import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
import type { LanguageScope } from '@/utils'; import type { LanguageScope } from '@/utils';
import { loadAntdLocale } from './AntdRootConfig';
import enUS from '../../../internal/web/translation/en-US.json'; import enUS from '../../../internal/web/translation/en-US.json';
const FALLBACK = 'en-US'; const FALLBACK = 'en-US';
@@ -43,6 +44,9 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
await i18next.changeLanguage(active); await i18next.changeLanguage(active);
} }
} }
// The html files ship lang="en"; screen readers pick their voice from it (s-ui#1293).
document.documentElement.lang = i18next.language;
await loadAntdLocale(i18next.language);
return i18next; return i18next;
} }
+43 -21
View File
@@ -100,7 +100,7 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
aria-label={ariaLabel} aria-label={ariaLabel}
title={ariaLabel} title={ariaLabel}
> >
<HeartOutlined /> <HeartOutlined aria-hidden />
</a> </a>
); );
} }
@@ -115,7 +115,7 @@ function DocsButton({ ariaLabel }: { ariaLabel: string }) {
aria-label={ariaLabel} aria-label={ariaLabel}
title={ariaLabel} title={ariaLabel}
> >
<ReadOutlined /> <ReadOutlined aria-hidden />
</a> </a>
); );
} }
@@ -132,7 +132,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
aria-label={`GitHub ${label}`} aria-label={`GitHub ${label}`}
title={label} title={label}
> >
<GithubOutlined /> <GithubOutlined aria-hidden />
{!collapsed && <span className="sider-version-text">{label}</span>} {!collapsed && <span className="sider-version-text">{label}</span>}
</a> </a>
); );
@@ -151,7 +151,13 @@ function ThemeCycleButton({
onCycle: () => void; onCycle: () => void;
ariaLabel: string; ariaLabel: string;
}) { }) {
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />; const icon = !isDark ? (
<SunOutlined aria-hidden />
) : !isUltra ? (
<MoonOutlined aria-hidden />
) : (
<MoonFilled aria-hidden />
);
return ( return (
<button <button
id={id} id={id}
@@ -248,42 +254,46 @@ export default function AppSidebar() {
const children: NonNullable<MenuProps['items']> = [ const children: NonNullable<MenuProps['items']> = [
{ {
key: '/settings#general', key: '/settings#general',
icon: <SettingOutlined />, icon: <SettingOutlined aria-hidden />,
label: t('pages.settings.panelSettings'), label: t('pages.settings.panelSettings'),
}, },
{ {
key: '/settings#security', key: '/settings#security',
icon: <SafetyOutlined />, icon: <SafetyOutlined aria-hidden />,
label: t('pages.settings.securitySettings'), label: t('pages.settings.securitySettings'),
}, },
{ {
key: '/settings#telegram', key: '/settings#telegram',
icon: <MessageOutlined />, icon: <MessageOutlined aria-hidden />,
label: t('pages.settings.TGBotSettings'), label: t('pages.settings.TGBotSettings'),
}, },
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') }, {
key: '/settings#email',
icon: <MailOutlined aria-hidden />,
label: t('pages.settings.emailSettings'),
},
{ {
key: '/settings#discord', key: '/settings#discord',
icon: <DiscordOutlined />, icon: <DiscordOutlined aria-hidden />,
label: t('pages.settings.discordSettings'), label: t('pages.settings.discordSettings'),
}, },
{ {
key: '/settings#subscription', key: '/settings#subscription',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
label: t('pages.settings.subSettings'), label: t('pages.settings.subSettings'),
}, },
]; ];
if (showSubFormats) { if (showSubFormats) {
children.push({ children.push({
key: '/settings#subscription-formats', key: '/settings#subscription-formats',
icon: <CodeOutlined />, icon: <CodeOutlined aria-hidden />,
label: t('menu.subFormats'), label: t('menu.subFormats'),
}); });
} }
if (showSubBalancers) { if (showSubBalancers) {
children.push({ children.push({
key: '/settings#subscription-balancers', key: '/settings#subscription-balancers',
icon: <ApartmentOutlined />, icon: <ApartmentOutlined aria-hidden />,
label: t('pages.settings.subBalancers.menu'), label: t('pages.settings.subBalancers.menu'),
}); });
} }
@@ -292,10 +302,22 @@ export default function AppSidebar() {
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>( const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [ () => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }, {
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') }, key: '/xray#basic',
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' }, icon: <SettingOutlined aria-hidden />,
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') }, label: t('pages.xray.basicTemplate'),
},
{
key: '/xray#balancer',
icon: <ClusterOutlined aria-hidden />,
label: t('pages.xray.Balancers'),
},
{ key: '/xray#dns', icon: <DatabaseOutlined aria-hidden />, label: 'DNS' },
{
key: '/xray#advanced',
icon: <CodeOutlined aria-hidden />,
label: t('pages.xray.advancedTemplate'),
},
], ],
[t], [t],
); );
@@ -394,7 +416,7 @@ export default function AppSidebar() {
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')} title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned} onClick={togglePinned}
> >
{pinned ? <PushpinFilled /> : <PushpinOutlined />} {pinned ? <PushpinFilled aria-hidden /> : <PushpinOutlined aria-hidden />}
</button> </button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} /> <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} /> <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -421,7 +443,7 @@ export default function AppSidebar() {
aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'} aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
> >
<span className="sidebar-command-left"> <span className="sidebar-command-left">
<SearchOutlined className="sidebar-command-icon" /> <SearchOutlined aria-hidden className="sidebar-command-icon" />
<span className="sidebar-command-text"> <span className="sidebar-command-text">
{t('commandPalette.search') || 'Search...'} {t('commandPalette.search') || 'Search...'}
</span> </span>
@@ -488,7 +510,7 @@ export default function AppSidebar() {
aria-label={t('close')} aria-label={t('close')}
onClick={() => setDrawerOpen(false)} onClick={() => setDrawerOpen(false)}
> >
<CloseOutlined /> <CloseOutlined aria-hidden />
</button> </button>
</div> </div>
</div> </div>
@@ -503,7 +525,7 @@ export default function AppSidebar() {
style={{ margin: '8px 12px 4px', width: 'calc(100% - 24px)' }} style={{ margin: '8px 12px 4px', width: 'calc(100% - 24px)' }}
> >
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SearchOutlined className="sidebar-command-icon" /> <SearchOutlined aria-hidden className="sidebar-command-icon" />
<span>{t('commandPalette.search') || 'Search...'}</span> <span>{t('commandPalette.search') || 'Search...'}</span>
</span> </span>
<span className="sidebar-command-kbd"> <span className="sidebar-command-kbd">
@@ -547,7 +569,7 @@ export default function AppSidebar() {
aria-label={t('menu.openMenu')} aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
> >
<MenuOutlined /> <MenuOutlined aria-hidden />
</button> </button>
)} )}
</div> </div>
@@ -38,7 +38,7 @@ export default function CustomSockoptList({
<Button <Button
type="dashed" type="dashed"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })} onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
> >
{t('pages.inbounds.form.addCustomOption')} {t('pages.inbounds.form.addCustomOption')}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useId, useRef, useState } from 'react';
import { import {
AutoComplete, AutoComplete,
Button, Button,
@@ -271,6 +271,7 @@ export default function FinalMaskForm({
showAll = false, showAll = false,
side = 'client', side = 'client',
}: FinalMaskFormProps) { }: FinalMaskFormProps) {
const uid = useId();
const base = asPath(name); const base = asPath(name);
// Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment // Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment
@@ -346,8 +347,9 @@ export default function FinalMaskForm({
)} )}
{showQuic && ( {showQuic && (
<> <>
<Form.Item label="QUIC Params"> <Form.Item label="QUIC Params" htmlFor={`${uid}-quic-params`}>
<Switch <Switch
id={`${uid}-quic-params`}
checked={hasQuicParams} checked={hasQuicParams}
onChange={(v) => { onChange={(v) => {
form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined); form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
@@ -385,7 +387,7 @@ function TcpMasksList({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => onClick={() =>
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') }) add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
@@ -531,12 +533,13 @@ function TcpMaskItem({
rules={[{ required: true, message: 'Password is required' }]} rules={[{ required: true, message: 'Password is required' }]}
> >
<Input <Input
aria-label="Password"
placeholder="Obfuscation password" placeholder="Obfuscation password"
style={{ width: 'calc(100% - 32px)' }} style={{ width: 'calc(100% - 32px)' }}
/> />
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')} aria-label={t('regenerate')}
onClick={() => onClick={() =>
form.setFieldValue( form.setFieldValue(
@@ -558,36 +561,47 @@ function TcpMaskItem({
} }
const FILTER_MASK_OPTION = { value: 'filter', label: 'Filter (source IPs)' }; const FILTER_MASK_OPTION = { value: 'filter', label: 'Filter (source IPs)' };
const FILTER_ALLOWED_HINT = 'Only these sources may connect; empty allows every source';
const FILTER_BLOCKED_HINT = 'These sources are dropped before any other mask runs';
// xray-core 26.10.21 (#7206) screens a listener's source IPs; a blocked match wins over an allowed // xray-core 26.10.21 (#7206) screens a listener's source IPs; a blocked match wins over an allowed
// one, and an empty list leaves that side open. Links and JSON subscriptions drop it. // one, and an empty list leaves that side open. Links and JSON subscriptions drop it.
function FilterMaskSettings({ fieldName }: { fieldName: number }) { function FilterMaskSettings({ fieldName }: { fieldName: number }) {
const uid = useId();
return ( return (
<> <>
<Form.Item <Form.Item
label="Allowed Source IPs" label="Allowed Source IPs"
name={[fieldName, 'settings', 'allowedSourceIPs']} name={[fieldName, 'settings', 'allowedSourceIPs']}
tooltip="Only these sources may connect; empty allows every source" tooltip={FILTER_ALLOWED_HINT}
> >
<Select <Select
aria-describedby={`${uid}-allowed-hint`}
mode="tags" mode="tags"
style={{ width: '100%' }} style={{ width: '100%' }}
tokenSeparators={[',', ' ']} tokenSeparators={[',', ' ']}
placeholder="10.0.0.0/8, geoip:ir" placeholder="10.0.0.0/8, geoip:ir"
/> />
</Form.Item> </Form.Item>
<span id={`${uid}-allowed-hint`} hidden>
{FILTER_ALLOWED_HINT}
</span>
<Form.Item <Form.Item
label="Blocked Source IPs" label="Blocked Source IPs"
name={[fieldName, 'settings', 'blockedSourceIPs']} name={[fieldName, 'settings', 'blockedSourceIPs']}
tooltip="These sources are dropped before any other mask runs" tooltip={FILTER_BLOCKED_HINT}
> >
<Select <Select
aria-describedby={`${uid}-blocked-hint`}
mode="tags" mode="tags"
style={{ width: '100%' }} style={{ width: '100%' }}
tokenSeparators={[',', ' ']} tokenSeparators={[',', ' ']}
placeholder="203.0.113.7, geoip:cn" placeholder="203.0.113.7, geoip:cn"
/> />
</Form.Item> </Form.Item>
<span id={`${uid}-blocked-hint`} hidden>
{FILTER_BLOCKED_HINT}
</span>
</> </>
); );
} }
@@ -683,7 +697,7 @@ function FragmentRangeList({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add('')} onClick={() => add('')}
/> />
@@ -776,7 +790,7 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add(defaultXmcProfile())} onClick={() => add(defaultXmcProfile())}
/> />
@@ -846,6 +860,21 @@ const HEADER_CUSTOM_TCP_GROUPS = [
}, },
] as const; ] as const;
const UDP_MODE_HINT =
'Prefix puts the header on every packet; Standalone exchanges it as separate handshake packets first';
const NOISE_RESET_HINT = 'Seconds before the noise is sent to a destination again; 0 sends it once';
const NOISE_SCRIPT_HINT =
'A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list';
const DOH_TLS_HINT =
'tlsSettings for the https:// addresses, e.g. a serverName to front the domain';
const DOH_QUIC_HINT = 'QUIC parameters for an https:// address reached over h3';
const REUSE_HINT = 'Writes the bytes an earlier item captured under this name';
const TRANSFORM_HINT =
'An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}';
const CAPTURE_HINT = 'Saves the bytes this item writes or reads under a name later items can reuse';
const UDP_HOP_HINT =
'Advertised to clients as a hop range; the server listens on its own port only';
function HeaderCustomGroups({ function HeaderCustomGroups({
tcpFieldName, tcpFieldName,
form, form,
@@ -856,6 +885,7 @@ function HeaderCustomGroups({
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
return ( return (
<> <>
{HEADER_CUSTOM_TCP_GROUPS.map(({ key: groupKey, label, tooltip }) => ( {HEADER_CUSTOM_TCP_GROUPS.map(({ key: groupKey, label, tooltip }) => (
@@ -866,10 +896,14 @@ function HeaderCustomGroups({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
aria-describedby={`${uid}-${groupKey}-hint`}
onClick={() => addGroup([defaultClientServerItem()])} onClick={() => addGroup([defaultClientServerItem()])}
/> />
<span id={`${uid}-${groupKey}-hint`} hidden>
{tooltip}
</span>
</Form.Item> </Form.Item>
{groups.map((group, gi) => ( {groups.map((group, gi) => (
<div key={group.key}> <div key={group.key}>
@@ -890,7 +924,7 @@ function HeaderCustomGroups({
<Form.Item label="Items"> <Form.Item label="Items">
<Button <Button
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => addItem(defaultClientServerItem())} onClick={() => addItem(defaultClientServerItem())}
/> />
@@ -950,7 +984,7 @@ function UdpMasksList({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => { onClick={() => {
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy'; const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
@@ -1228,6 +1262,7 @@ function SalamanderUdpMaskSettings({
absolutePath: (string | number)[]; absolutePath: (string | number)[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const packetSizePath = [...absolutePath, 'settings', 'packetSize']; const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true }); const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander'; const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
@@ -1235,6 +1270,7 @@ function SalamanderUdpMaskSettings({
return ( return (
<> <>
<Form.Item <Form.Item
htmlFor={`${uid}-salamander-mode`}
label="Mode" label="Mode"
extra={ extra={
mode === 'gecko' mode === 'gecko'
@@ -1243,6 +1279,7 @@ function SalamanderUdpMaskSettings({
} }
> >
<Select <Select
id={`${uid}-salamander-mode`}
value={mode} value={mode}
onChange={(next) => { onChange={(next) => {
if (next === 'gecko') { if (next === 'gecko') {
@@ -1274,10 +1311,14 @@ function SalamanderUdpMaskSettings({
noStyle noStyle
rules={[{ validator: validateSalamanderPassword }]} rules={[{ validator: validateSalamanderPassword }]}
> >
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} /> <Input
aria-label="Password"
placeholder="Obfuscation password"
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')} aria-label={t('regenerate')}
onClick={() => onClick={() =>
form.setFieldValue( form.setFieldValue(
@@ -1304,9 +1345,11 @@ function SalamanderUdpMaskSettings({
} }
function GeckoPacketSizeInput({ function GeckoPacketSizeInput({
id,
value, value,
onChange, onChange,
}: { }: {
id?: string;
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
}) { }) {
@@ -1315,6 +1358,7 @@ function GeckoPacketSizeInput({
return ( return (
<Space.Compact block> <Space.Compact block>
<InputNumber <InputNumber
id={id}
prefix="Min" prefix="Min"
min={GECKO_MIN_PACKET_SIZE} min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE} max={GECKO_MAX_PACKET_SIZE}
@@ -1325,6 +1369,7 @@ function GeckoPacketSizeInput({
style={{ width: '50%' }} style={{ width: '50%' }}
/> />
<InputNumber <InputNumber
aria-label="Packet size max"
prefix="Max" prefix="Max"
min={GECKO_MIN_PACKET_SIZE} min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE} max={GECKO_MAX_PACKET_SIZE}
@@ -1348,14 +1393,12 @@ function UdpHeaderCustom({
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
return ( return (
<> <>
<Form.Item <Form.Item label="Mode" name={[udpFieldName, 'settings', 'mode']} tooltip={UDP_MODE_HINT}>
label="Mode"
name={[udpFieldName, 'settings', 'mode']}
tooltip="Prefix puts the header on every packet; Standalone exchanges it as separate handshake packets first"
>
<Select <Select
aria-describedby={`${uid}-mode-hint`}
allowClear allowClear
placeholder="Prefix" placeholder="Prefix"
options={[ options={[
@@ -1364,6 +1407,9 @@ function UdpHeaderCustom({
]} ]}
/> />
</Form.Item> </Form.Item>
<span id={`${uid}-mode-hint`} hidden>
{UDP_MODE_HINT}
</span>
{(['client', 'server'] as const).map((groupKey) => ( {(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}> <Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => ( {(items, { add, remove }) => (
@@ -1372,7 +1418,7 @@ function UdpHeaderCustom({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add(defaultUdpClientServerItem())} onClick={() => add(defaultUdpClientServerItem())}
/> />
@@ -1418,6 +1464,7 @@ const XDNS_RECORD_TYPE_OPTIONS = [
// Resolvers and extraPoll are read by clients only; a server keeps them for the share link. // Resolvers and extraPoll are read by clients only; a server keeps them for the share link.
function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
return ( return (
<> <>
<Form.List name={[udpFieldName, 'settings', 'domains']}> <Form.List name={[udpFieldName, 'settings', 'domains']}>
@@ -1427,7 +1474,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })} onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
/> />
@@ -1481,7 +1528,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add({ addrs: [] })} onClick={() => add({ addrs: [] })}
/> />
@@ -1513,17 +1560,29 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Form.Item <Form.Item
label="DoH TLS" label="DoH TLS"
name={[resolver.name, 'tlsConfig']} name={[resolver.name, 'tlsConfig']}
tooltip="tlsSettings for the https:// addresses, e.g. a serverName to front the domain" tooltip={DOH_TLS_HINT}
> >
<JsonObjectInput placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}' /> <JsonObjectInput
aria-describedby={`${uid}-doh-tls-hint-${ri}`}
placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}'
/>
</Form.Item> </Form.Item>
<span id={`${uid}-doh-tls-hint-${ri}`} hidden>
{DOH_TLS_HINT}
</span>
<Form.Item <Form.Item
label="DoH QUIC" label="DoH QUIC"
name={[resolver.name, 'quicConfig']} name={[resolver.name, 'quicConfig']}
tooltip="QUIC parameters for an https:// address reached over h3" tooltip={DOH_QUIC_HINT}
> >
<JsonObjectInput placeholder='{"congestion": "bbr"}' /> <JsonObjectInput
aria-describedby={`${uid}-doh-quic-hint-${ri}`}
placeholder='{"congestion": "bbr"}'
/>
</Form.Item> </Form.Item>
<span id={`${uid}-doh-quic-hint-${ri}`} hidden>
{DOH_QUIC_HINT}
</span>
</div> </div>
))} ))}
</> </>
@@ -1619,25 +1678,32 @@ function NoiseItems({
form: FormInstance; form: FormInstance;
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const uid = useId();
const script = Form.useWatch([...absoluteSettingsPath, 'script'], form) as string | undefined; const script = Form.useWatch([...absoluteSettingsPath, 'script'], form) as string | undefined;
return ( return (
<> <>
<Form.Item <Form.Item
label="Reset" label="Reset"
name={[udpFieldName, 'settings', 'reset']} name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once" tooltip={NOISE_RESET_HINT}
rules={[{ validator: validateNoiseReset }]} rules={[{ validator: validateNoiseReset }]}
> >
<Input placeholder="0 or 60-120" /> <Input placeholder="0 or 60-120" aria-describedby={`${uid}-reset-hint`} />
</Form.Item> </Form.Item>
<span id={`${uid}-reset-hint`} hidden>
{NOISE_RESET_HINT}
</span>
<Form.Item <Form.Item
label="Script" label="Script"
name={[udpFieldName, 'settings', 'script']} name={[udpFieldName, 'settings', 'script']}
tooltip="A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list" tooltip={NOISE_SCRIPT_HINT}
normalize={(value: string) => value || undefined} normalize={(value: string) => value || undefined}
> >
<Input allowClear placeholder="noise.lua" /> <Input allowClear placeholder="noise.lua" aria-describedby={`${uid}-script-hint`} />
</Form.Item> </Form.Item>
<span id={`${uid}-script-hint`} hidden>
{NOISE_SCRIPT_HINT}
</span>
{!script && ( {!script && (
<NoiseItemList <NoiseItemList
udpFieldName={udpFieldName} udpFieldName={udpFieldName}
@@ -1668,7 +1734,7 @@ function NoiseItemList({
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => add(defaultNoiseItem())} onClick={() => add(defaultNoiseItem())}
/> />
@@ -1728,10 +1794,14 @@ function validateCustomVarName(_: unknown, value: unknown) {
// Hands the form only a parsed JSON object, so half-typed text never reaches the config. // Hands the form only a parsed JSON object, so half-typed text never reaches the config.
function JsonObjectInput({ function JsonObjectInput({
id,
'aria-describedby': ariaDescribedBy,
value, value,
onChange, onChange,
placeholder, placeholder,
}: { }: {
id?: string;
'aria-describedby'?: string;
value?: Record<string, unknown>; value?: Record<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void; onChange?: (next: Record<string, unknown> | undefined) => void;
placeholder?: string; placeholder?: string;
@@ -1741,6 +1811,8 @@ function JsonObjectInput({
return ( return (
<> <>
<Input.TextArea <Input.TextArea
id={id}
aria-describedby={ariaDescribedBy}
value={text} value={text}
autoSize={{ minRows: 2, maxRows: 8 }} autoSize={{ minRows: 2, maxRows: 8 }}
placeholder={placeholder} placeholder={placeholder}
@@ -1783,6 +1855,7 @@ function ItemEditor({
onRemove?: () => void; onRemove?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [kind, setKind] = useState<ItemKind>(() => const [kind, setKind] = useState<ItemKind>(() =>
allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes', allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes',
); );
@@ -1816,8 +1889,13 @@ function ItemEditor({
return ( return (
<> <>
{allowVariables && ( {allowVariables && (
<Form.Item label="Kind"> <Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<Select value={kind} onChange={onKindChange} options={ITEM_KIND_OPTIONS} /> <Select
id={`${uid}-kind`}
value={kind}
onChange={onKindChange}
options={ITEM_KIND_OPTIONS}
/>
</Form.Item> </Form.Item>
)} )}
{kind === 'bytes' && ( {kind === 'bytes' && (
@@ -1848,27 +1926,40 @@ function ItemEditor({
)} )}
{kind === 'reuse' && ( {kind === 'reuse' && (
<Form.Item <>
label="Reuse" <Form.Item
name={[fieldName, 'reuse']} label="Reuse"
tooltip="Writes the bytes an earlier item captured under this name" name={[fieldName, 'reuse']}
rules={[ tooltip={REUSE_HINT}
{ required: true, message: 'name the captured variable' }, rules={[
{ validator: validateCustomVarName }, { required: true, message: 'name the captured variable' },
]} { validator: validateCustomVarName },
> ]}
<Input placeholder="variable name" /> >
</Form.Item> <Input placeholder="variable name" aria-describedby={`${uid}-reuse-hint`} />
</Form.Item>
<span id={`${uid}-reuse-hint`} hidden>
{REUSE_HINT}
</span>
</>
)} )}
{kind === 'transform' && ( {kind === 'transform' && (
<Form.Item <>
label="Transform" <Form.Item
name={[fieldName, 'transform']} label="Transform"
tooltip='An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}' name={[fieldName, 'transform']}
rules={[{ required: true, message: 'enter the transform expression' }]} tooltip={TRANSFORM_HINT}
> rules={[{ required: true, message: 'enter the transform expression' }]}
<JsonObjectInput placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}' /> >
</Form.Item> <JsonObjectInput
aria-describedby={`${uid}-transform-hint`}
placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}'
/>
</Form.Item>
<span id={`${uid}-transform-hint`} hidden>
{TRANSFORM_HINT}
</span>
</>
)} )}
<Form.Item <Form.Item
@@ -1910,10 +2001,14 @@ function ItemEditor({
<Form.Item label="Packet"> <Form.Item label="Packet">
<Space.Compact block> <Space.Compact block>
<Form.Item name={[fieldName, 'packet']} noStyle> <Form.Item name={[fieldName, 'packet']} noStyle>
<Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} /> <Input
aria-label="Packet"
placeholder="binary data"
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')} aria-label={t('regenerate')}
onClick={() => onClick={() =>
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64()) form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())
@@ -1932,15 +2027,20 @@ function ItemEditor({
</Form.Item> </Form.Item>
{allowVariables && ( {allowVariables && (
<Form.Item <>
label="Capture" <Form.Item
name={[fieldName, 'capture']} label="Capture"
tooltip="Saves the bytes this item writes or reads under a name later items can reuse" name={[fieldName, 'capture']}
normalize={(v) => (v === '' ? undefined : v)} tooltip={CAPTURE_HINT}
rules={[{ validator: validateCustomVarName }]} normalize={(v) => (v === '' ? undefined : v)}
> rules={[{ validator: validateCustomVarName }]}
<Input placeholder="variable name" /> >
</Form.Item> <Input placeholder="variable name" aria-describedby={`${uid}-capture-hint`} />
</Form.Item>
<span id={`${uid}-capture-hint`} hidden>
{CAPTURE_HINT}
</span>
</>
)} )}
</> </>
); );
@@ -1955,6 +2055,7 @@ function QuicParamsForm({
form: FormInstance; form: FormInstance;
showUdpHop: boolean; showUdpHop: boolean;
}) { }) {
const uid = useId();
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined; const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
| Record<string, unknown> | Record<string, unknown>
@@ -2012,16 +2113,18 @@ function QuicParamsForm({
itself listens on its own port. */} itself listens on its own port. */}
{showUdpHop && ( {showUdpHop && (
<> <>
<Form.Item <Form.Item htmlFor={`${uid}-udp-hop`} label="UDP Hop" tooltip={UDP_HOP_HINT}>
label="UDP Hop"
tooltip="Advertised to clients as a hop range; the server listens on its own port only"
>
<Switch <Switch
id={`${uid}-udp-hop`}
aria-describedby={`${uid}-udp-hop-hint`}
checked={hasUdpHop} checked={hasUdpHop}
onChange={(v) => { onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined); form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}} }}
/> />
<span id={`${uid}-udp-hop-hint`} hidden>
{UDP_HOP_HINT}
</span>
</Form.Item> </Form.Item>
{hasUdpHop && ( {hasUdpHop && (
<> <>
+8 -5
View File
@@ -8,6 +8,7 @@ import '@/styles/page-cards.css';
import { setupHttp } from '@/api/http-init'; import { setupHttp } from '@/api/http-init';
import { readyI18n } from '@/i18n/react'; import { readyI18n } from '@/i18n/react';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider'; import { QueryProvider } from '@/api/QueryProvider';
import { router } from '@/routes'; import { router } from '@/routes';
@@ -49,11 +50,13 @@ readyI18n().then(() => {
const root = document.getElementById('app'); const root = document.getElementById('app');
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<ThemeProvider> <AntdRootConfig>
<QueryProvider> <ThemeProvider>
<RouterProvider router={router} /> <QueryProvider>
</QueryProvider> <RouterProvider router={router} />
</ThemeProvider>, </QueryProvider>
</ThemeProvider>
</AntdRootConfig>,
); );
} }
}); });
@@ -111,6 +111,7 @@ export default function BulkAttachInboundsModal({
onChange={setTargetIds} onChange={setTargetIds}
options={targetOptions} options={targetOptions}
placeholder={t('pages.clients.attachToInboundsTargets')} placeholder={t('pages.clients.attachToInboundsTargets')}
aria-label={t('pages.clients.attachToInboundsTargets')}
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
autoFocus autoFocus
/> />
@@ -111,6 +111,7 @@ export default function BulkDetachInboundsModal({
onChange={setTargetIds} onChange={setTargetIds}
options={targetOptions} options={targetOptions}
placeholder={t('pages.clients.detachFromInboundsTargets')} placeholder={t('pages.clients.detachFromInboundsTargets')}
aria-label={t('pages.clients.detachFromInboundsTargets')}
showSearch={{ optionFilterProp: 'label' }} showSearch={{ optionFilterProp: 'label' }}
autoFocus autoFocus
/> />
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AutoComplete, AutoComplete,
@@ -82,6 +82,7 @@ export default function ClientBulkAddModal({
onSaved, onSaved,
}: ClientBulkAddModalProps) { }: ClientBulkAddModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients({ list: false }); const { bulkCreate } = useClients({ list: false });
@@ -254,13 +255,18 @@ export default function ClientBulkAddModal({
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}> <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required> <Form.Item
label={t('pages.clients.attachedInbounds')}
required
htmlFor={`${uid}-inbounds`}
>
<SelectAllClearButtons <SelectAllClearButtons
options={inboundOptions} options={inboundOptions}
value={inboundIds} value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)} onChange={(v) => methods.setValue('inboundIds', v)}
/> />
<Select <Select
id={`${uid}-inbounds`}
mode="multiple" mode="multiple"
value={inboundIds} value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)} onChange={(v) => methods.setValue('inboundIds', v)}
@@ -323,16 +329,17 @@ export default function ClientBulkAddModal({
</FormField> </FormField>
)} )}
<Form.Item label={t('pages.clients.subId')}> <Form.Item label={t('pages.clients.subId')} htmlFor={`${uid}-sub-id`}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-sub-id`}
value={subId} value={subId}
onChange={(e) => methods.setValue('subId', e.target.value)} onChange={(e) => methods.setValue('subId', e.target.value)}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))} onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
/> />
</Space.Compact> </Space.Compact>
@@ -376,10 +383,11 @@ export default function ClientBulkAddModal({
</FormField> </FormField>
)} )}
<Form.Item label={t('pages.clients.limitIp')}> <Form.Item label={t('pages.clients.limitIp')} htmlFor={`${uid}-limit-ip`}>
<Tooltip title={limitIpNotice || undefined}> <Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'inline-flex' }}> <span style={{ display: 'inline-flex' }}>
<InputNumber <InputNumber
id={`${uid}-limit-ip`}
value={limitIp} value={limitIp}
min={0} min={0}
disabled={limitIpDisabled} disabled={limitIpDisabled}
@@ -399,8 +407,9 @@ export default function ClientBulkAddModal({
<InputNumber min={0} step={1} /> <InputNumber min={0} step={1} />
</FormField> </FormField>
<Form.Item label={t('pages.clients.delayedStart')}> <Form.Item label={t('pages.clients.delayedStart')} htmlFor={`${uid}-delayed-start`}>
<Switch <Switch
id={`${uid}-delayed-start`}
checked={delayedStart} checked={delayedStart}
onClick={() => { onClick={() => {
setDelayedStart(!delayedStart); setDelayedStart(!delayedStart);
@@ -410,16 +419,18 @@ export default function ClientBulkAddModal({
</Form.Item> </Form.Item>
{delayedStart ? ( {delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}> <Form.Item label={t('pages.clients.expireDays')} htmlFor={`${uid}-expire-days`}>
<InputNumber <InputNumber
id={`${uid}-expire-days`}
value={delayedExpireDays} value={delayedExpireDays}
min={0} min={0}
onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))} onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
/> />
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item label={t('pages.inbounds.expireDate')}> <Form.Item label={t('pages.inbounds.expireDate')} htmlFor={`${uid}-expiry`}>
<DateTimePicker <DateTimePicker
id={`${uid}-expiry`}
value={expiryDate} value={expiryDate}
onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)} onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
/> />
+87 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AutoComplete, AutoComplete,
@@ -256,6 +256,7 @@ export default function ClientFormModal({
onOpenChange, onOpenChange,
}: ClientFormModalProps) { }: ClientFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
@@ -820,7 +821,7 @@ export default function ClientFormModal({
<Button <Button
color="danger" color="danger"
variant="filled" variant="filled"
icon={<RetweetOutlined />} icon={<RetweetOutlined aria-hidden />}
loading={resetting} loading={resetting}
> >
{t('pages.inbounds.resetTraffic')} {t('pages.inbounds.resetTraffic')}
@@ -848,9 +849,14 @@ export default function ClientFormModal({
<> <>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item label={t('pages.clients.email')} required> <Form.Item
label={t('pages.clients.email')}
required
htmlFor={`${uid}-email`}
>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-email`}
value={email} value={email}
placeholder={t('pages.clients.email')} placeholder={t('pages.clients.email')}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -859,7 +865,7 @@ export default function ClientFormModal({
{!isEdit && ( {!isEdit && (
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => onClick={() =>
methods.setValue('email', RandomUtil.randomLowerAndNum(12)) methods.setValue('email', RandomUtil.randomLowerAndNum(12))
} }
@@ -880,6 +886,7 @@ export default function ClientFormModal({
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <Form.Item
htmlFor={`${uid}-limit-ip`}
label={t('pages.clients.limitIp')} label={t('pages.clients.limitIp')}
tooltip={t('pages.clients.limitIpDesc')} tooltip={t('pages.clients.limitIpDesc')}
> >
@@ -887,6 +894,8 @@ export default function ClientFormModal({
<span style={{ display: 'flex', width: '100%' }}> <span style={{ display: 'flex', width: '100%' }}>
<Space.Compact style={{ display: 'flex', flex: 1 }}> <Space.Compact style={{ display: 'flex', flex: 1 }}>
<InputNumber <InputNumber
id={`${uid}-limit-ip`}
aria-describedby={`${uid}-limit-ip-hint`}
value={limitIp} value={limitIp}
min={0} min={0}
disabled={limitIpDisabled} disabled={limitIpDisabled}
@@ -900,7 +909,7 @@ export default function ClientFormModal({
<Tooltip title={t('pages.clients.ipLog')}> <Tooltip title={t('pages.clients.ipLog')}>
<Button <Button
aria-label={t('pages.clients.ipLog')} aria-label={t('pages.clients.ipLog')}
icon={<EyeOutlined />} icon={<EyeOutlined aria-hidden />}
loading={ipsLoading} loading={ipsLoading}
onClick={openIpsModal} onClick={openIpsModal}
> >
@@ -911,15 +920,21 @@ export default function ClientFormModal({
</Space.Compact> </Space.Compact>
</span> </span>
</Tooltip> </Tooltip>
<span id={`${uid}-limit-ip-hint`} hidden>
{t('pages.clients.limitIpDesc')}
</span>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <Form.Item
htmlFor={`${uid}-limit-hwid`}
label={t('pages.clients.limitHwid')} label={t('pages.clients.limitHwid')}
tooltip={t('pages.clients.limitHwidDesc')} tooltip={t('pages.clients.limitHwidDesc')}
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<InputNumber <InputNumber
id={`${uid}-limit-hwid`}
aria-describedby={`${uid}-limit-hwid-hint`}
value={limitHwid} value={limitHwid}
min={0} min={0}
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -929,7 +944,7 @@ export default function ClientFormModal({
<Tooltip title={t('pages.clients.hwidLog')}> <Tooltip title={t('pages.clients.hwidLog')}>
<Button <Button
aria-label={t('pages.clients.hwidLog')} aria-label={t('pages.clients.hwidLog')}
icon={<EyeOutlined />} icon={<EyeOutlined aria-hidden />}
loading={hwidsLoading} loading={hwidsLoading}
onClick={openHwidsModal} onClick={openHwidsModal}
> >
@@ -938,6 +953,9 @@ export default function ClientFormModal({
</Tooltip> </Tooltip>
)} )}
</Space.Compact> </Space.Compact>
<span id={`${uid}-limit-hwid-hint`} hidden>
{t('pages.clients.limitHwidDesc')}
</span>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -953,8 +971,12 @@ export default function ClientFormModal({
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</FormField> </FormField>
) : ( ) : (
<Form.Item label={t('pages.clients.expiryTime')}> <Form.Item
label={t('pages.clients.expiryTime')}
htmlFor={`${uid}-expiry`}
>
<DateTimePicker <DateTimePicker
id={`${uid}-expiry`}
value={expiryDayjs} value={expiryDayjs}
onChange={(d) => onChange={(d) =>
methods.setValue('expiryDate', d ? d.valueOf() : 0) methods.setValue('expiryDate', d ? d.valueOf() : 0)
@@ -964,8 +986,12 @@ export default function ClientFormModal({
)} )}
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Form.Item label={t('pages.clients.delayedStart')}> <Form.Item
label={t('pages.clients.delayedStart')}
htmlFor={`${uid}-delayed-start`}
>
<Switch <Switch
id={`${uid}-delayed-start`}
checked={delayedStart} checked={delayedStart}
onChange={(v) => { onChange={(v) => {
methods.setValue('delayedStart', v); methods.setValue('delayedStart', v);
@@ -1062,13 +1088,18 @@ export default function ClientFormModal({
</Row> </Row>
)} )}
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}> <Form.Item
label={t('pages.clients.attachedInbounds')}
required={!isEdit}
htmlFor={`${uid}-inbounds`}
>
<SelectAllClearButtons <SelectAllClearButtons
options={inboundOptions} options={inboundOptions}
value={inboundIds} value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)} onChange={(v) => methods.setValue('inboundIds', v)}
/> />
<Select <Select
id={`${uid}-inbounds`}
mode="multiple" mode="multiple"
value={inboundIds} value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)} onChange={(v) => methods.setValue('inboundIds', v)}
@@ -1102,77 +1133,96 @@ export default function ClientFormModal({
label: t('pages.clients.tabCredentials'), label: t('pages.clients.tabCredentials'),
children: ( children: (
<> <>
<Form.Item label={t('pages.clients.uuid')}> <Form.Item label={t('pages.clients.uuid')} htmlFor={`${uid}-uuid`}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-uuid`}
value={uuid} value={uuid}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => methods.setValue('uuid', e.target.value)} onChange={(e) => methods.setValue('uuid', e.target.value)}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())} onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())}
/> />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
htmlFor={`${uid}-password`}
label={t('pages.clients.password')} label={t('pages.clients.password')}
tooltip={t('pages.clients.passwordDesc')} tooltip={t('pages.clients.passwordDesc')}
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-password`}
aria-describedby={`${uid}-password-hint`}
value={password} value={password}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => methods.setValue('password', e.target.value)} onChange={(e) => methods.setValue('password', e.target.value)}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={regeneratePassword} onClick={regeneratePassword}
/> />
</Space.Compact> </Space.Compact>
<span id={`${uid}-password-hint`} hidden>
{t('pages.clients.passwordDesc')}
</span>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
htmlFor={`${uid}-sub-id`}
label={t('pages.clients.subId')} label={t('pages.clients.subId')}
tooltip={t('pages.clients.subIdDesc')} tooltip={t('pages.clients.subIdDesc')}
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-sub-id`}
aria-describedby={`${uid}-sub-id-hint`}
value={subId} value={subId}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => methods.setValue('subId', e.target.value)} onChange={(e) => methods.setValue('subId', e.target.value)}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => onClick={() =>
methods.setValue('subId', RandomUtil.randomLowerAndNum(16)) methods.setValue('subId', RandomUtil.randomLowerAndNum(16))
} }
/> />
</Space.Compact> </Space.Compact>
<span id={`${uid}-sub-id-hint`} hidden>
{t('pages.clients.subIdDesc')}
</span>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
htmlFor={`${uid}-auth`}
label={t('pages.clients.hysteriaAuth')} label={t('pages.clients.hysteriaAuth')}
tooltip={t('pages.clients.hysteriaAuthDesc')} tooltip={t('pages.clients.hysteriaAuthDesc')}
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-auth`}
aria-describedby={`${uid}-auth-hint`}
value={auth} value={auth}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => methods.setValue('auth', e.target.value)} onChange={(e) => methods.setValue('auth', e.target.value)}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => onClick={() =>
methods.setValue('auth', RandomUtil.randomLowerAndNum(16)) methods.setValue('auth', RandomUtil.randomLowerAndNum(16))
} }
/> />
</Space.Compact> </Space.Compact>
<span id={`${uid}-auth-hint`} hidden>
{t('pages.clients.hysteriaAuthDesc')}
</span>
</Form.Item> </Form.Item>
{showFlow && ( {showFlow && (
@@ -1195,6 +1245,7 @@ export default function ClientFormModal({
{(showWireguard || showAmneziawg) && ( {(showWireguard || showAmneziawg) && (
<> <>
<Form.Item <Form.Item
htmlFor={`${uid}-wg-private-key`}
label={t( label={t(
showAmneziawg showAmneziawg
? 'pages.clients.amneziaWgPrivateKey' ? 'pages.clients.amneziaWgPrivateKey'
@@ -1203,6 +1254,7 @@ export default function ClientFormModal({
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-wg-private-key`}
value={wgPrivateKey} value={wgPrivateKey}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => { onChange={(e) => {
@@ -1216,7 +1268,7 @@ export default function ClientFormModal({
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={regenerateWireguardKeys} onClick={regenerateWireguardKeys}
/> />
</Space.Compact> </Space.Compact>
@@ -1232,6 +1284,7 @@ export default function ClientFormModal({
<Input disabled /> <Input disabled />
</FormField> </FormField>
<Form.Item <Form.Item
htmlFor={`${uid}-wg-psk`}
label={t( label={t(
showAmneziawg showAmneziawg
? 'pages.clients.amneziaWgPreSharedKey' ? 'pages.clients.amneziaWgPreSharedKey'
@@ -1240,11 +1293,11 @@ export default function ClientFormModal({
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<FormField name="wgPreSharedKey" noStyle> <FormField name="wgPreSharedKey" noStyle>
<Input style={{ flex: 1 }} /> <Input id={`${uid}-wg-psk`} style={{ flex: 1 }} />
</FormField> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={regenerateWireguardPresharedKey} onClick={regenerateWireguardPresharedKey}
/> />
</Space.Compact> </Space.Compact>
@@ -1305,18 +1358,20 @@ export default function ClientFormModal({
{showMtproto && ( {showMtproto && (
<> <>
<Form.Item <Form.Item
htmlFor={`${uid}-mtproto-secret`}
label={t('pages.clients.mtprotoSecret')} label={t('pages.clients.mtprotoSecret')}
extra={t('pages.clients.mtprotoSecretHint')} extra={t('pages.clients.mtprotoSecretHint')}
> >
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input <Input
id={`${uid}-mtproto-secret`}
value={secret} value={secret}
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => methods.setValue('secret', e.target.value)} onChange={(e) => methods.setValue('secret', e.target.value)}
/> />
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={regenerateMtprotoSecret} onClick={regenerateMtprotoSecret}
/> />
</Space.Compact> </Space.Compact>
@@ -1344,7 +1399,7 @@ export default function ClientFormModal({
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => addExternalLinkRow('link')} onClick={() => addExternalLinkRow('link')}
> >
{t('pages.clients.addExternalLink')} {t('pages.clients.addExternalLink')}
@@ -1364,7 +1419,7 @@ export default function ClientFormModal({
valueProp="checked" valueProp="checked"
noStyle noStyle
> >
<Switch size="small" /> <Switch size="small" aria-label={t('enable')} />
</FormField> </FormField>
<span>{t('enable')}</span> <span>{t('enable')}</span>
</div> </div>
@@ -1378,7 +1433,7 @@ export default function ClientFormModal({
<Button <Button
aria-label={t('delete')} aria-label={t('delete')}
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={() => removeExternalLink(index)} onClick={() => removeExternalLink(index)}
/> />
</Tooltip> </Tooltip>
@@ -1398,6 +1453,7 @@ export default function ClientFormModal({
const hasSpecificExpiry = Number(expiryField.value) > 0; const hasSpecificExpiry = Number(expiryField.value) > 0;
return ( return (
<DateTimePicker <DateTimePicker
aria-label={t('pages.clients.expiryTime')}
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null} value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
@@ -1415,7 +1471,7 @@ export default function ClientFormModal({
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => addExternalLinkRow('subscription')} onClick={() => addExternalLinkRow('subscription')}
> >
{t('pages.clients.addExternalSubscription')} {t('pages.clients.addExternalSubscription')}
@@ -1435,7 +1491,7 @@ export default function ClientFormModal({
valueProp="checked" valueProp="checked"
noStyle noStyle
> >
<Switch size="small" /> <Switch size="small" aria-label={t('enable')} />
</FormField> </FormField>
<span>{t('enable')}</span> <span>{t('enable')}</span>
</div> </div>
@@ -1449,7 +1505,7 @@ export default function ClientFormModal({
<Button <Button
aria-label={t('delete')} aria-label={t('delete')}
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={() => removeExternalLink(index)} onClick={() => removeExternalLink(index)}
/> />
</Tooltip> </Tooltip>
@@ -1475,6 +1531,7 @@ export default function ClientFormModal({
const hasSpecificExpiry = Number(expiryField.value) > 0; const hasSpecificExpiry = Number(expiryField.value) > 0;
return ( return (
<DateTimePicker <DateTimePicker
aria-label={t('pages.clients.expiryTime')}
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null} value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
@@ -1515,7 +1572,12 @@ export default function ClientFormModal({
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX} zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
onCancel={() => setIpsModalOpen(false)} onCancel={() => setIpsModalOpen(false)}
footer={[ footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}> <Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={ipsLoading}
onClick={loadIps}
>
{t('refresh')} {t('refresh')}
</Button>, </Button>,
<Button <Button
+23 -18
View File
@@ -350,7 +350,7 @@ export default function ClientInfoModal({
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(client.subId!)} onClick={() => copyValue(client.subId!)}
/> />
@@ -365,7 +365,7 @@ export default function ClientInfoModal({
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(client.uuid!)} onClick={() => copyValue(client.uuid!)}
/> />
@@ -380,7 +380,7 @@ export default function ClientInfoModal({
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(client.password!)} onClick={() => copyValue(client.password!)}
/> />
@@ -395,7 +395,7 @@ export default function ClientInfoModal({
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(client.auth!)} onClick={() => copyValue(client.auth!)}
/> />
@@ -457,7 +457,7 @@ export default function ClientInfoModal({
<td> <td>
<Button <Button
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined aria-hidden />}
aria-label={t('pages.clients.ipLog')} aria-label={t('pages.clients.ipLog')}
loading={ipsLoading} loading={ipsLoading}
onClick={openIpsModal} onClick={openIpsModal}
@@ -489,7 +489,7 @@ export default function ClientInfoModal({
<td> <td>
<Button <Button
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined aria-hidden />}
aria-label={t('pages.clients.hwidLog')} aria-label={t('pages.clients.hwidLog')}
loading={hwidsLoading} loading={hwidsLoading}
onClick={openHwidsModal} onClick={openHwidsModal}
@@ -591,7 +591,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(subLink)} onClick={() => copyValue(subLink)}
/> />
@@ -599,7 +599,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
loading={downloadingFormat === 'standard'} loading={downloadingFormat === 'standard'}
disabled={downloadingFormat !== null} disabled={downloadingFormat !== null}
@@ -621,7 +621,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button <Button
size="small" size="small"
icon={<QrcodeOutlined />} icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')} aria-label={t('pages.clients.qrCode')}
/> />
</Tooltip> </Tooltip>
@@ -646,7 +646,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(subJsonLink)} onClick={() => copyValue(subJsonLink)}
/> />
@@ -654,7 +654,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
loading={downloadingFormat === 'json'} loading={downloadingFormat === 'json'}
disabled={downloadingFormat !== null} disabled={downloadingFormat !== null}
@@ -676,7 +676,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button <Button
size="small" size="small"
icon={<QrcodeOutlined />} icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')} aria-label={t('pages.clients.qrCode')}
/> />
</Tooltip> </Tooltip>
@@ -704,7 +704,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(subClashLink)} onClick={() => copyValue(subClashLink)}
/> />
@@ -712,7 +712,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
loading={downloadingFormat === 'clash'} loading={downloadingFormat === 'clash'}
disabled={downloadingFormat !== null} disabled={downloadingFormat !== null}
@@ -734,7 +734,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button <Button
size="small" size="small"
icon={<QrcodeOutlined />} icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')} aria-label={t('pages.clients.qrCode')}
/> />
</Tooltip> </Tooltip>
@@ -768,7 +768,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyValue(link)} onClick={() => copyValue(link)}
/> />
@@ -783,7 +783,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button <Button
size="small" size="small"
icon={<QrcodeOutlined />} icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')} aria-label={t('pages.clients.qrCode')}
/> />
</Tooltip> </Tooltip>
@@ -853,7 +853,12 @@ export default function ClientInfoModal({
width={440} width={440}
onCancel={() => setIpsModalOpen(false)} onCancel={() => setIpsModalOpen(false)}
footer={[ footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}> <Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={ipsLoading}
onClick={loadIps}
>
{t('refresh')} {t('refresh')}
</Button>, </Button>,
<Button <Button
+41 -31
View File
@@ -113,7 +113,7 @@ function UngroupIcon() {
height: '1em', height: '1em',
}} }}
> >
<TagsOutlined /> <TagsOutlined aria-hidden />
<span <span
aria-hidden="true" aria-hidden="true"
style={{ style={{
@@ -1081,6 +1081,7 @@ export default function ClientsPage() {
checked={!!record.enable} checked={!!record.enable}
size="small" size="small"
loading={togglingEmail === record.email} loading={togglingEmail === record.email}
aria-label={t('pages.clients.enabled')}
onChange={(next) => onToggleEnable(record, next)} onChange={(next) => onToggleEnable(record, next)}
/> />
), ),
@@ -1140,16 +1141,20 @@ export default function ClientsPage() {
render: (_v, record) => { render: (_v, record) => {
if (!record.group) return <Typography.Text type="secondary">—</Typography.Text>; if (!record.group) return <Typography.Text type="secondary">—</Typography.Text>;
const isActive = filters.groups.includes(record.group); const isActive = filters.groups.includes(record.group);
const filterByGroup = () => {
if (!isActive) setFilters({ ...filters, groups: [...filters.groups, record.group!] });
};
return ( return (
<Tag <Tag
color="geekblue" color="geekblue"
style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }} style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
role="button"
tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!isActive) { filterByGroup();
setFilters({ ...filters, groups: [...filters.groups, record.group!] });
}
}} }}
onKeyDown={activateOnKey(filterByGroup)}
> >
{record.group} {record.group}
</Tag> </Tag>
@@ -1327,7 +1332,7 @@ export default function ClientsPage() {
<SummaryStat <SummaryStat
title={t('clients')} title={t('clients')}
value={summary.total} value={summary.total}
prefix={<TeamOutlined />} prefix={<TeamOutlined aria-hidden />}
onSelect={() => selectBucket(null)} onSelect={() => selectBucket(null)}
/> />
</Col> </Col>
@@ -1393,7 +1398,7 @@ export default function ClientsPage() {
{selectedRowKeys.length === 0 ? ( {selectedRowKeys.length === 0 ? (
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={onAdd} onClick={onAdd}
aria-label={t('pages.clients.addClients')} aria-label={t('pages.clients.addClients')}
> >
@@ -1418,20 +1423,20 @@ export default function ClientsPage() {
? [ ? [
{ {
key: 'attach', key: 'attach',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.clients.attach'), label: t('pages.clients.attach'),
onClick: () => setBulkAttachOpen(true), onClick: () => setBulkAttachOpen(true),
}, },
{ {
key: 'detach', key: 'detach',
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined aria-hidden />,
label: t('pages.clients.detach'), label: t('pages.clients.detach'),
danger: true, danger: true,
onClick: () => setBulkDetachOpen(true), onClick: () => setBulkDetachOpen(true),
}, },
{ {
key: 'addToGroup', key: 'addToGroup',
icon: <TagsOutlined />, icon: <TagsOutlined aria-hidden />,
label: t('pages.clients.addToGroup'), label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true), onClick: () => setBulkGroupOpen(true),
}, },
@@ -1445,26 +1450,26 @@ export default function ClientsPage() {
{ type: 'divider' as const }, { type: 'divider' as const },
{ {
key: 'enable', key: 'enable',
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined aria-hidden />,
label: t('pages.clients.enable'), label: t('pages.clients.enable'),
onClick: () => onBulkSetEnable(true), onClick: () => onBulkSetEnable(true),
}, },
{ {
key: 'disable', key: 'disable',
icon: <StopOutlined />, icon: <StopOutlined aria-hidden />,
label: t('pages.clients.disable'), label: t('pages.clients.disable'),
danger: true, danger: true,
onClick: () => onBulkSetEnable(false), onClick: () => onBulkSetEnable(false),
}, },
{ {
key: 'adjust', key: 'adjust',
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined aria-hidden />,
label: t('pages.clients.adjust'), label: t('pages.clients.adjust'),
onClick: () => setBulkAdjustOpen(true), onClick: () => setBulkAdjustOpen(true),
}, },
{ {
key: 'subLinks', key: 'subLinks',
icon: <LinkOutlined />, icon: <LinkOutlined aria-hidden />,
label: t('pages.clients.subLinks'), label: t('pages.clients.subLinks'),
onClick: () => setSubLinksOpen(true), onClick: () => setSubLinksOpen(true),
}, },
@@ -1472,39 +1477,39 @@ export default function ClientsPage() {
: [ : [
{ {
key: 'bulk', key: 'bulk',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.clients.bulk'), label: t('pages.clients.bulk'),
onClick: () => setBulkAddOpen(true), onClick: () => setBulkAddOpen(true),
}, },
{ {
key: 'export', key: 'export',
icon: <DownloadOutlined />, icon: <DownloadOutlined aria-hidden />,
label: t('pages.clients.exportClients'), label: t('pages.clients.exportClients'),
onClick: onExportClients, onClick: onExportClients,
}, },
{ {
key: 'import', key: 'import',
icon: <UploadOutlined />, icon: <UploadOutlined aria-hidden />,
label: t('pages.clients.importClients'), label: t('pages.clients.importClients'),
onClick: onImportClients, onClick: onImportClients,
}, },
{ {
key: 'resetAll', key: 'resetAll',
icon: <RetweetOutlined />, icon: <RetweetOutlined aria-hidden />,
label: t('pages.clients.resetAllTraffics'), label: t('pages.clients.resetAllTraffics'),
onClick: onResetAllTraffics, onClick: onResetAllTraffics,
}, },
{ type: 'divider' as const }, { type: 'divider' as const },
{ {
key: 'delDepleted', key: 'delDepleted',
icon: <RestOutlined />, icon: <RestOutlined aria-hidden />,
label: t('pages.clients.delDepleted'), label: t('pages.clients.delDepleted'),
danger: true, danger: true,
onClick: onDelDepleted, onClick: onDelDepleted,
}, },
{ {
key: 'delOrphans', key: 'delOrphans',
icon: <DisconnectOutlined />, icon: <DisconnectOutlined aria-hidden />,
label: t('pages.clients.delOrphans'), label: t('pages.clients.delOrphans'),
danger: true, danger: true,
onClick: onDeleteOrphans, onClick: onDeleteOrphans,
@@ -1512,14 +1517,14 @@ export default function ClientsPage() {
], ],
}} }}
> >
<Button icon={<MoreOutlined />} aria-label={t('more')}> <Button icon={<MoreOutlined aria-hidden />} aria-label={t('more')}>
{!isMobile && t('more')} {!isMobile && t('more')}
</Button> </Button>
</Dropdown> </Dropdown>
{selectedRowKeys.length > 0 && ( {selectedRowKeys.length > 0 && (
<Button <Button
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={onBulkDelete} onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }} style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')} aria-label={t('delete')}
@@ -1536,14 +1541,14 @@ export default function ClientsPage() {
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('pages.clients.searchPlaceholder')} placeholder={t('pages.clients.searchPlaceholder')}
allowClear allowClear
prefix={<SearchOutlined />} prefix={<SearchOutlined aria-hidden />}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }} style={{ maxWidth: 320 }}
aria-label={t('search')} aria-label={t('search')}
/> />
<Badge count={activeCount} size="small" offset={[-4, 4]}> <Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button <Button
icon={<FilterOutlined />} icon={<FilterOutlined aria-hidden />}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)} onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'} type={activeCount > 0 ? 'primary' : 'default'}
@@ -1556,7 +1561,7 @@ export default function ClientsPage() {
value={sortValueFor(sortColumn, sortOrder)} value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')} aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
suffix={<SortAscendingOutlined />} suffix={<SortAscendingOutlined aria-hidden />}
style={{ minWidth: isMobile ? 130 : 200 }} style={{ minWidth: isMobile ? 130 : 200 }}
onChange={(value) => { onChange={(value) => {
const opt = SORT_OPTIONS.find((o) => o.value === value); const opt = SORT_OPTIONS.find((o) => o.value === value);
@@ -1709,7 +1714,10 @@ export default function ClientsPage() {
locale={{ locale={{
emptyText: ( emptyText: (
<div className="clients-empty"> <div className="clients-empty">
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <TeamOutlined
aria-hidden
style={{ fontSize: 32, marginBottom: 8 }}
/>
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
), ),
@@ -1734,7 +1742,7 @@ export default function ClientsPage() {
)} )}
{filteredClients.length === 0 && ( {filteredClients.length === 0 && (
<div className="card-empty"> <div className="card-empty">
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} /> <TeamOutlined aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
)} )}
@@ -1799,6 +1807,7 @@ export default function ClientsPage() {
checked={!!row.enable} checked={!!row.enable}
size="small" size="small"
loading={togglingEmail === row.email} loading={togglingEmail === row.email}
aria-label={t('pages.clients.enabled')}
onChange={(next) => onToggleEnable(row, next)} onChange={(next) => onToggleEnable(row, next)}
/> />
<Dropdown <Dropdown
@@ -1810,7 +1819,8 @@ export default function ClientsPage() {
key: 'qr', key: 'qr',
label: ( label: (
<> <>
<QrcodeOutlined /> {t('pages.clients.qrCode')} <QrcodeOutlined aria-hidden />{' '}
{t('pages.clients.qrCode')}
</> </>
), ),
onClick: () => onShowQr(row.email), onClick: () => onShowQr(row.email),
@@ -1819,7 +1829,7 @@ export default function ClientsPage() {
key: 'reset', key: 'reset',
label: ( label: (
<> <>
<RetweetOutlined />{' '} <RetweetOutlined aria-hidden />{' '}
{t('pages.inbounds.resetTraffic')} {t('pages.inbounds.resetTraffic')}
</> </>
), ),
@@ -1829,7 +1839,7 @@ export default function ClientsPage() {
key: 'edit', key: 'edit',
label: ( label: (
<> <>
<EditOutlined /> {t('edit')} <EditOutlined aria-hidden /> {t('edit')}
</> </>
), ),
onClick: () => onEdit(row.email), onClick: () => onEdit(row.email),
@@ -1839,7 +1849,7 @@ export default function ClientsPage() {
danger: true, danger: true,
label: ( label: (
<> <>
<DeleteOutlined /> {t('delete')} <DeleteOutlined aria-hidden /> {t('delete')}
</> </>
), ),
onClick: () => onDelete(row.email), onClick: () => onDelete(row.email),
@@ -1851,7 +1861,7 @@ export default function ClientsPage() {
type="text" type="text"
size="small" size="small"
className="row-action-trigger" className="row-action-trigger"
icon={<MoreOutlined />} icon={<MoreOutlined aria-hidden />}
aria-label={t('more')} aria-label={t('more')}
/> />
</Dropdown> </Dropdown>
+15 -7
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
@@ -46,6 +46,7 @@ export default function FilterDrawer({
nodes, nodes,
}: FilterDrawerProps) { }: FilterDrawerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) { function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) {
onChange({ ...filters, [key]: value }); onChange({ ...filters, [key]: value });
@@ -113,8 +114,9 @@ export default function FilterDrawer({
</Checkbox.Group> </Checkbox.Group>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.protocol')}> <Form.Item label={t('pages.inbounds.protocol')} htmlFor={`${uid}-protocols`}>
<Select <Select
id={`${uid}-protocols`}
mode="multiple" mode="multiple"
value={filters.protocols} value={filters.protocols}
onChange={(v) => patch('protocols', v as string[])} onChange={(v) => patch('protocols', v as string[])}
@@ -125,8 +127,9 @@ export default function FilterDrawer({
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('inbounds')}> <Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
<Select <Select
id={`${uid}-inbounds`}
mode="multiple" mode="multiple"
value={filters.inboundIds} value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])} onChange={(v) => patch('inboundIds', v as number[])}
@@ -140,8 +143,9 @@ export default function FilterDrawer({
</Form.Item> </Form.Item>
{nodes.length > 0 && ( {nodes.length > 0 && (
<Form.Item label={t('pages.clients.filters.nodes')}> <Form.Item label={t('pages.clients.filters.nodes')} htmlFor={`${uid}-nodes`}>
<Select <Select
id={`${uid}-nodes`}
mode="multiple" mode="multiple"
value={filters.nodeIds} value={filters.nodeIds}
onChange={(v) => patch('nodeIds', v as number[])} onChange={(v) => patch('nodeIds', v as number[])}
@@ -155,8 +159,9 @@ export default function FilterDrawer({
</Form.Item> </Form.Item>
)} )}
<Form.Item label={t('pages.clients.group')}> <Form.Item label={t('pages.clients.group')} htmlFor={`${uid}-groups`}>
<Select <Select
id={`${uid}-groups`}
mode="multiple" mode="multiple"
value={filters.groups} value={filters.groups}
onChange={(v) => patch('groups', v as string[])} onChange={(v) => patch('groups', v as string[])}
@@ -169,8 +174,9 @@ export default function FilterDrawer({
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.expiryTime')}> <Form.Item label={t('pages.clients.expiryTime')} htmlFor={`${uid}-expiry`}>
<DatePicker.RangePicker <DatePicker.RangePicker
id={`${uid}-expiry`}
value={dateRange} value={dateRange}
onChange={(range) => { onChange={(range) => {
const from = range?.[0]?.startOf('day').valueOf(); const from = range?.[0]?.startOf('day').valueOf();
@@ -182,10 +188,11 @@ export default function FilterDrawer({
/> />
</Form.Item> </Form.Item>
<Form.Item label={`${t('pages.clients.traffic')} (GB)`}> <Form.Item label={`${t('pages.clients.traffic')} (GB)`} htmlFor={`${uid}-usage-from`}>
<Row gutter={8}> <Row gutter={8}>
<Col span={12}> <Col span={12}>
<InputNumber <InputNumber
id={`${uid}-usage-from`}
value={filters.usageFromGB} value={filters.usageFromGB}
min={0} min={0}
step={1} step={1}
@@ -196,6 +203,7 @@ export default function FilterDrawer({
</Col> </Col>
<Col span={12}> <Col span={12}>
<InputNumber <InputNumber
aria-label={`${t('pages.clients.traffic')} (GB) ${t('to')}`}
value={filters.usageToGB} value={filters.usageToGB}
min={0} min={0}
step={1} step={1}
+5 -5
View File
@@ -44,7 +44,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small" size="small"
type="text" type="text"
style={ICON_BUTTON_STYLE} style={ICON_BUTTON_STYLE}
icon={<QrcodeOutlined />} icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')} aria-label={t('pages.clients.qrCode')}
onClick={() => onShowQr(email)} onClick={() => onShowQr(email)}
/> />
@@ -54,7 +54,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small" size="small"
type="text" type="text"
style={ICON_BUTTON_STYLE} style={ICON_BUTTON_STYLE}
icon={<InfoCircleOutlined />} icon={<InfoCircleOutlined aria-hidden />}
aria-label={t('pages.clients.clientInfo')} aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(email)} onClick={() => onShowInfo(email)}
/> />
@@ -64,7 +64,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small" size="small"
type="text" type="text"
style={ICON_BUTTON_STYLE} style={ICON_BUTTON_STYLE}
icon={<RetweetOutlined />} icon={<RetweetOutlined aria-hidden />}
aria-label={t('pages.inbounds.resetTraffic')} aria-label={t('pages.inbounds.resetTraffic')}
onClick={() => onResetTraffic(email)} onClick={() => onResetTraffic(email)}
/> />
@@ -74,7 +74,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small" size="small"
type="text" type="text"
style={ICON_BUTTON_STYLE} style={ICON_BUTTON_STYLE}
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
aria-label={t('edit')} aria-label={t('edit')}
onClick={() => onEdit(email)} onClick={() => onEdit(email)}
/> />
@@ -85,7 +85,7 @@ export const ClientRowActions = memo(function ClientRowActions({
type="text" type="text"
danger danger
style={ICON_BUTTON_STYLE} style={ICON_BUTTON_STYLE}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
aria-label={t('delete')} aria-label={t('delete')}
onClick={() => onDelete(email)} onClick={() => onDelete(email)}
/> />
+3 -3
View File
@@ -122,7 +122,7 @@ export default function SubLinksModal({
size="small" size="small"
type="text" type="text"
aria-label={t('copy')} aria-label={t('copy')}
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
onClick={() => copy(row.link, t('copied'))} onClick={() => copy(row.link, t('copied'))}
/> />
), ),
@@ -157,7 +157,7 @@ export default function SubLinksModal({
<Button onClick={() => onOpenChange(false)}>{t('close')}</Button> <Button onClick={() => onOpenChange(false)}>{t('close')}</Button>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
disabled={rows.length === 0} disabled={rows.length === 0}
onClick={() => onClick={() =>
copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length })) copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length }))
@@ -167,7 +167,7 @@ export default function SubLinksModal({
</Button> </Button>
<Button <Button
type="primary" type="primary"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
disabled={rows.length === 0} disabled={rows.length === 0}
onClick={download} onClick={download}
> >
@@ -138,6 +138,7 @@ export default function GroupAddClientsModal({
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
aria-label={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }} style={{ maxWidth: 320 }}
/> />
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -122,6 +122,7 @@ export default function GroupRemoveClientsModal({
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
aria-label={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }} style={{ maxWidth: 320 }}
/> />
<Typography.Text type="secondary"> <Typography.Text type="secondary">
+25 -20
View File
@@ -1,4 +1,4 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { lazy, useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
@@ -89,6 +89,7 @@ async function fetchEmailsForGroup(name: string): Promise<string[]> {
export default function GroupsPage() { export default function GroupsPage() {
usePageTitle(); usePageTitle();
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
@@ -353,41 +354,41 @@ export default function GroupsPage() {
return [ return [
{ {
key: 'subLinks', key: 'subLinks',
icon: <LinkOutlined />, icon: <LinkOutlined aria-hidden />,
label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }), label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount, disabled: !row.clientCount,
onClick: () => openSubLinksFor(row), onClick: () => openSubLinksFor(row),
}, },
{ {
key: 'adjust', key: 'adjust',
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined aria-hidden />,
label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }), label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount, disabled: !row.clientCount,
onClick: () => openAdjustFor(row), onClick: () => openAdjustFor(row),
}, },
{ {
key: 'reset', key: 'reset',
icon: <RetweetOutlined />, icon: <RetweetOutlined aria-hidden />,
label: t('pages.groups.resetTraffic'), label: t('pages.groups.resetTraffic'),
disabled: !row.clientCount, disabled: !row.clientCount,
onClick: () => onResetTraffic(row), onClick: () => onResetTraffic(row),
}, },
{ {
key: 'addClients', key: 'addClients',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.groups.addToGroup'), label: t('pages.groups.addToGroup'),
onClick: () => openAddClientsFor(row), onClick: () => openAddClientsFor(row),
}, },
{ {
key: 'rename', key: 'rename',
icon: <EditOutlined />, icon: <EditOutlined aria-hidden />,
label: t('pages.groups.rename'), label: t('pages.groups.rename'),
onClick: () => openRename(row), onClick: () => openRename(row),
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
key: 'removeClients', key: 'removeClients',
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined aria-hidden />,
label: t('pages.groups.removeFromGroup'), label: t('pages.groups.removeFromGroup'),
danger: true, danger: true,
disabled: !row.clientCount, disabled: !row.clientCount,
@@ -395,7 +396,7 @@ export default function GroupsPage() {
}, },
{ {
key: 'deleteClients', key: 'deleteClients',
icon: <DeleteOutlined />, icon: <DeleteOutlined aria-hidden />,
label: t('pages.groups.deleteClients'), label: t('pages.groups.deleteClients'),
danger: true, danger: true,
disabled: !row.clientCount, disabled: !row.clientCount,
@@ -403,7 +404,7 @@ export default function GroupsPage() {
}, },
{ {
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined aria-hidden />,
label: t('pages.groups.deleteGroupOnly'), label: t('pages.groups.deleteGroupOnly'),
danger: true, danger: true,
onClick: () => onDelete(row), onClick: () => onDelete(row),
@@ -424,7 +425,7 @@ export default function GroupsPage() {
size="small" size="small"
type="text" type="text"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<MoreOutlined />} icon={<MoreOutlined aria-hidden />}
/> />
</Dropdown> </Dropdown>
<Tooltip title={t('pages.groups.rename')}> <Tooltip title={t('pages.groups.rename')}>
@@ -433,7 +434,7 @@ export default function GroupsPage() {
size="small" size="small"
type="text" type="text"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
onClick={() => openRename(row)} onClick={() => openRename(row)}
/> />
</Tooltip> </Tooltip>
@@ -518,14 +519,14 @@ export default function GroupsPage() {
<Statistic <Statistic
title={t('pages.groups.totalGroups')} title={t('pages.groups.totalGroups')}
value={String(totalGroups)} value={String(totalGroups)}
prefix={<TagsOutlined />} prefix={<TagsOutlined aria-hidden />}
/> />
</Col> </Col>
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
<Statistic <Statistic
title={t('pages.groups.totalGroupedClients')} title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)} value={String(totalClients)}
prefix={<TeamOutlined />} prefix={<TeamOutlined aria-hidden />}
/> />
</Col> </Col>
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
@@ -534,9 +535,11 @@ export default function GroupsPage() {
value={0} value={0}
formatter={() => ( formatter={() => (
<span> <span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)} <ArrowUpOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totalUpload)}
{' / '} {' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)} <ArrowDownOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totalDownload)}
</span> </span>
)} )}
/> />
@@ -545,7 +548,7 @@ export default function GroupsPage() {
<Statistic <Statistic
title={t('pages.groups.totalTraffic')} title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)} value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<PieChartOutlined />} prefix={<PieChartOutlined aria-hidden />}
/> />
</Col> </Col>
</Row> </Row>
@@ -561,7 +564,7 @@ export default function GroupsPage() {
<Button <Button
aria-label={t('pages.groups.addGroup')} aria-label={t('pages.groups.addGroup')}
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={openCreate} onClick={openCreate}
> >
{!isMobile && t('pages.groups.addGroup')} {!isMobile && t('pages.groups.addGroup')}
@@ -579,7 +582,7 @@ export default function GroupsPage() {
locale={{ locale={{
emptyText: ( emptyText: (
<div className="card-empty"> <div className="card-empty">
<TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <TagsOutlined aria-hidden style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
), ),
@@ -604,8 +607,9 @@ export default function GroupsPage() {
destroyOnHidden destroyOnHidden
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label={t('pages.groups.name')}> <Form.Item label={t('pages.groups.name')} htmlFor={`${uid}-create-name`}>
<Input <Input
id={`${uid}-create-name`}
value={createName} value={createName}
onChange={(e) => setCreateName(e.target.value)} onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreate} onPressEnter={confirmCreate}
@@ -627,8 +631,9 @@ export default function GroupsPage() {
destroyOnHidden destroyOnHidden
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label={t('pages.groups.name')}> <Form.Item label={t('pages.groups.name')} htmlFor={`${uid}-rename-name`}>
<Input <Input
id={`${uid}-rename-name`}
value={renameValue} value={renameValue}
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onPressEnter={confirmRename} onPressEnter={confirmRename}
+12 -8
View File
@@ -208,7 +208,11 @@ export default function HostFormModal({
{ {
key: 'basic', key: 'basic',
forceRender: true, forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile), label: catTabLabel(
<ProfileOutlined aria-hidden />,
t('pages.hosts.sections.basic'),
isMobile,
),
children: ( children: (
<> <>
<FormField <FormField
@@ -291,7 +295,7 @@ export default function HostFormModal({
key: 'security', key: 'security',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<SafetyCertificateOutlined />, <SafetyCertificateOutlined aria-hidden />,
t('pages.hosts.sections.security'), t('pages.hosts.sections.security'),
isMobile, isMobile,
), ),
@@ -373,7 +377,7 @@ export default function HostFormModal({
key: 'advanced', key: 'advanced',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<ControlOutlined />, <ControlOutlined aria-hidden />,
t('pages.hosts.sections.advanced'), t('pages.hosts.sections.advanced'),
isMobile, isMobile,
), ),
@@ -386,7 +390,7 @@ export default function HostFormModal({
key: 'adv-general', key: 'adv-general',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<SettingOutlined />, <SettingOutlined aria-hidden />,
t('pages.hosts.sections.general'), t('pages.hosts.sections.general'),
isMobile, isMobile,
), ),
@@ -425,7 +429,7 @@ export default function HostFormModal({
key: 'adv-mux', key: 'adv-mux',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<PartitionOutlined />, <PartitionOutlined aria-hidden />,
t('pages.hosts.fields.muxParams'), t('pages.hosts.fields.muxParams'),
isMobile, isMobile,
), ),
@@ -445,7 +449,7 @@ export default function HostFormModal({
key: 'adv-sockopt', key: 'adv-sockopt',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<DeploymentUnitOutlined />, <DeploymentUnitOutlined aria-hidden />,
t('pages.hosts.fields.sockoptParams'), t('pages.hosts.fields.sockoptParams'),
isMobile, isMobile,
), ),
@@ -465,7 +469,7 @@ export default function HostFormModal({
key: 'adv-finalmask', key: 'adv-finalmask',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<RocketOutlined />, <RocketOutlined aria-hidden />,
t('pages.hosts.fields.finalMask'), t('pages.hosts.fields.finalMask'),
isMobile, isMobile,
), ),
@@ -489,7 +493,7 @@ export default function HostFormModal({
key: 'clash', key: 'clash',
forceRender: true, forceRender: true,
label: catTabLabel( label: catTabLabel(
<NodeIndexOutlined />, <NodeIndexOutlined aria-hidden />,
t('pages.hosts.sections.clash'), t('pages.hosts.sections.clash'),
isMobile, isMobile,
), ),
+13 -8
View File
@@ -95,7 +95,7 @@ export default function HostList(props: HostListProps) {
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<ArrowUpOutlined />} icon={<ArrowUpOutlined aria-hidden />}
aria-label={t('pages.hosts.moveUp')} aria-label={t('pages.hosts.moveUp')}
disabled={idx === 0} disabled={idx === 0}
onClick={() => onMove(h, 'up')} onClick={() => onMove(h, 'up')}
@@ -105,7 +105,7 @@ export default function HostList(props: HostListProps) {
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<ArrowDownOutlined />} icon={<ArrowDownOutlined aria-hidden />}
aria-label={t('pages.hosts.moveDown')} aria-label={t('pages.hosts.moveDown')}
disabled={idx >= count - 1} disabled={idx >= count - 1}
onClick={() => onMove(h, 'down')} onClick={() => onMove(h, 'down')}
@@ -115,7 +115,7 @@ export default function HostList(props: HostListProps) {
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
aria-label={t('edit')} aria-label={t('edit')}
onClick={() => onEdit(h)} onClick={() => onEdit(h)}
/> />
@@ -125,7 +125,7 @@ export default function HostList(props: HostListProps) {
size="small" size="small"
type="text" type="text"
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
aria-label={t('delete')} aria-label={t('delete')}
onClick={() => onDelete(h)} onClick={() => onDelete(h)}
/> />
@@ -139,7 +139,12 @@ export default function HostList(props: HostListProps) {
key: 'enable', key: 'enable',
width: 90, width: 90,
render: (_, h) => ( render: (_, h) => (
<Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} /> <Switch
size="small"
checked={!h.isDisabled}
aria-label={t('pages.hosts.fields.enable')}
onChange={(next) => onToggleEnable(h, next)}
/>
), ),
}, },
{ {
@@ -276,7 +281,7 @@ export default function HostList(props: HostListProps) {
const toolbar = ( const toolbar = (
<div className="card-toolbar"> <div className="card-toolbar">
{selectedGroupIds.length === 0 ? ( {selectedGroupIds.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={onAdd}>
{!isMobile && t('pages.hosts.addHost')} {!isMobile && t('pages.hosts.addHost')}
</Button> </Button>
) : ( ) : (
@@ -291,7 +296,7 @@ export default function HostList(props: HostListProps) {
</Tag> </Tag>
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button> <Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button> <Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}> <Button danger icon={<DeleteOutlined aria-hidden />} onClick={onBulkDelete}>
{t('pages.hosts.bulkDelete')} {t('pages.hosts.bulkDelete')}
</Button> </Button>
</> </>
@@ -316,7 +321,7 @@ export default function HostList(props: HostListProps) {
locale={{ locale={{
emptyText: ( emptyText: (
<div className="card-empty"> <div className="card-empty">
<GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <GlobalOutlined aria-hidden style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
), ),
+9 -3
View File
@@ -174,7 +174,7 @@ export default function HostsPage() {
<Statistic <Statistic
title={t('pages.hosts.summary.total')} title={t('pages.hosts.summary.total')}
value={String(summary.total)} value={String(summary.total)}
prefix={<GlobalOutlined />} prefix={<GlobalOutlined aria-hidden />}
/> />
</Col> </Col>
<Col xs={8} sm={8} md={8}> <Col xs={8} sm={8} md={8}>
@@ -182,7 +182,10 @@ export default function HostsPage() {
title={t('pages.hosts.summary.enabled')} title={t('pages.hosts.summary.enabled')}
value={String(summary.enabled)} value={String(summary.enabled)}
prefix={ prefix={
<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} /> <CheckCircleOutlined
aria-hidden
style={{ color: 'var(--ant-color-success)' }}
/>
} }
/> />
</Col> </Col>
@@ -191,7 +194,10 @@ export default function HostsPage() {
title={t('pages.hosts.summary.disabled')} title={t('pages.hosts.summary.disabled')}
value={String(summary.disabled)} value={String(summary.disabled)}
prefix={ prefix={
<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} /> <StopOutlined
aria-hidden
style={{ color: 'var(--ant-color-text-quaternary)' }}
/>
} }
/> />
</Col> </Col>
+6 -4
View File
@@ -754,9 +754,11 @@ export default function InboundsPage() {
value={0} value={0}
formatter={() => ( formatter={() => (
<span> <span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)} <ArrowUpOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totals.up)}
{' / '} {' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)} <ArrowDownOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totals.down)}
</span> </span>
)} )}
/> />
@@ -765,14 +767,14 @@ export default function InboundsPage() {
<Statistic <Statistic
title={t('pages.inbounds.totalUsage')} title={t('pages.inbounds.totalUsage')}
value={SizeFormatter.sizeFormat(totals.up + totals.down)} value={SizeFormatter.sizeFormat(totals.up + totals.down)}
prefix={<PieChartOutlined />} prefix={<PieChartOutlined aria-hidden />}
/> />
</Col> </Col>
<Col xs={24} sm={24} md={8}> <Col xs={24} sm={24} md={8}>
<Statistic <Statistic
title={t('pages.inbounds.inboundCount')} title={t('pages.inbounds.inboundCount')}
value={String(dbInbounds.length)} value={String(dbInbounds.length)}
prefix={<BarsOutlined />} prefix={<BarsOutlined aria-hidden />}
/> />
</Col> </Col>
</Row> </Row>
@@ -32,7 +32,13 @@ export default function FallbacksCard({
const addButtons = ( const addButtons = (
<Space size={8} wrap> <Space size={8} wrap>
<Button type="primary" ghost size="small" icon={<PlusOutlined />} onClick={addFallback}> <Button
type="primary"
ghost
size="small"
icon={<PlusOutlined aria-hidden />}
onClick={addFallback}
>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'} {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button> </Button>
<Button <Button
@@ -90,25 +96,26 @@ export default function FallbacksCard({
disabled={idx === 0} disabled={idx === 0}
onClick={() => moveFallback(idx, -1)} onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')} title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />} icon={<ArrowUpOutlined aria-hidden />}
/> />
<Button <Button
aria-label={t('pages.inbounds.form.moveDown')} aria-label={t('pages.inbounds.form.moveDown')}
disabled={idx === fallbacks.length - 1} disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)} onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')} title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />} icon={<ArrowDownOutlined aria-hidden />}
/> />
<Button <Button
aria-label={t('delete')} aria-label={t('delete')}
danger danger
onClick={() => removeFallback(idx)} onClick={() => removeFallback(idx)}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
/> />
</Space.Compact> </Space.Compact>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Input <Input
aria-label="SNI"
prefix="SNI" prefix="SNI"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'} placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name} value={record.name}
@@ -117,6 +124,7 @@ export default function FallbacksCard({
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Input <Input
aria-label="ALPN"
prefix="ALPN" prefix="ALPN"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'} placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn} value={record.alpn}
@@ -125,6 +133,7 @@ export default function FallbacksCard({
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Input <Input
aria-label="Path"
prefix="Path" prefix="Path"
placeholder="/" placeholder="/"
value={record.path} value={record.path}
@@ -133,6 +142,7 @@ export default function FallbacksCard({
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Input <Input
aria-label="Dest"
prefix="Dest" prefix="Dest"
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'} placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest} value={record.dest}
@@ -141,6 +151,7 @@ export default function FallbacksCard({
</Col> </Col>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<InputNumber <InputNumber
aria-label="xver"
prefix="xver" prefix="xver"
min={0} min={0}
max={2} max={2}
@@ -1,20 +1,8 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
Alert,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
Tooltip,
message,
} from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
@@ -99,16 +87,6 @@ import SniffingTab from './SniffingTab';
import type { DBInbound } from '@/models/dbinbound'; import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
const labelWithHint = (label: string, hint: string) => (
<span>
{label}
<Tooltip title={hint}>
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
</Tooltip>
</span>
);
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const; const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE = const SHARE_ADDR_HOSTNAME_RE =
@@ -240,6 +218,7 @@ export default function InboundFormModal({
availableNodesFetched = true, availableNodesFetched = true,
}: InboundFormModalProps) { }: InboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() }); const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
@@ -668,7 +647,8 @@ export default function InboundFormModal({
<FormField <FormField
name="listen" name="listen"
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))} label={t('pages.inbounds.address')}
tooltip={t('pages.inbounds.form.listenHelp')}
> >
<Input placeholder={t('pages.inbounds.monitorDesc')} /> <Input placeholder={t('pages.inbounds.monitorDesc')} />
</FormField> </FormField>
@@ -677,10 +657,8 @@ export default function InboundFormModal({
<> <>
<FormField <FormField
name="shareAddrStrategy" name="shareAddrStrategy"
label={labelWithHint( label={t('pages.inbounds.form.shareAddrStrategy')}
t('pages.inbounds.form.shareAddrStrategy'), tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
t('pages.inbounds.form.shareAddrStrategyHelp'),
)}
> >
<Select <Select
options={SHARE_ADDR_STRATEGIES.filter( options={SHARE_ADDR_STRATEGIES.filter(
@@ -695,10 +673,8 @@ export default function InboundFormModal({
{shareAddrStrategy === 'custom' && ( {shareAddrStrategy === 'custom' && (
<FormField <FormField
name="shareAddr" name="shareAddr"
label={labelWithHint( label={t('pages.inbounds.form.shareAddr')}
t('pages.inbounds.form.shareAddr'), tooltip={t('pages.inbounds.form.shareAddrHelp')}
t('pages.inbounds.form.shareAddrHelp'),
)}
rules={{ rules={{
validate: (value) => validate: (value) =>
isValidShareAddrInput(String(value ?? '')) || isValidShareAddrInput(String(value ?? '')) ||
@@ -713,10 +689,8 @@ export default function InboundFormModal({
<FormField <FormField
name="subSortIndex" name="subSortIndex"
label={labelWithHint( label={t('pages.inbounds.form.subSortIndex')}
t('pages.inbounds.form.subSortIndex'), tooltip={t('pages.inbounds.form.subSortIndexHelp')}
t('pages.inbounds.form.subSortIndexHelp'),
)}
> >
<InputNumber /> <InputNumber />
</FormField> </FormField>
@@ -724,10 +698,8 @@ export default function InboundFormModal({
<FormField <FormField
name="excludeFromSub" name="excludeFromSub"
valueProp="checked" valueProp="checked"
label={labelWithHint( label={t('pages.inbounds.form.excludeFromSub')}
t('pages.inbounds.form.excludeFromSub'), tooltip={t('pages.inbounds.form.excludeFromSubHelp')}
t('pages.inbounds.form.excludeFromSubHelp'),
)}
> >
<Switch /> <Switch />
</FormField> </FormField>
@@ -736,10 +708,8 @@ export default function InboundFormModal({
<FormField <FormField
name="disableFlow" name="disableFlow"
valueProp="checked" valueProp="checked"
label={labelWithHint( label={t('pages.inbounds.form.disableFlow')}
t('pages.inbounds.form.disableFlow'), tooltip={t('pages.inbounds.form.disableFlowHelp')}
t('pages.inbounds.form.disableFlowHelp'),
)}
> >
<Switch /> <Switch />
</FormField> </FormField>
@@ -754,13 +724,13 @@ export default function InboundFormModal({
</FormField> </FormField>
<Form.Item <Form.Item
label={ htmlFor={`${uid}-totalFlow`}
<Tooltip title={t('pages.inbounds.meansNoLimit')}> label={t('pages.inbounds.totalFlow')}
{t('pages.inbounds.totalFlow')} tooltip={t('pages.inbounds.meansNoLimit')}
</Tooltip>
}
> >
<InputNumber <InputNumber
id={`${uid}-totalFlow`}
aria-describedby={`${uid}-totalFlow-hint`}
value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0} value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
min={0} min={0}
step={1} step={1}
@@ -769,6 +739,9 @@ export default function InboundFormModal({
setV('total', bytes); setV('total', bytes);
}} }}
/> />
<span id={`${uid}-totalFlow-hint`} hidden>
{t('pages.inbounds.meansNoLimit')}
</span>
</Form.Item> </Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}> <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
@@ -791,16 +764,19 @@ export default function InboundFormModal({
)} )}
<Form.Item <Form.Item
label={ htmlFor={`${uid}-expiryTime`}
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}> label={t('pages.inbounds.expireDate')}
{t('pages.inbounds.expireDate')} tooltip={t('pages.inbounds.leaveBlankToNeverExpire')}
</Tooltip>
}
> >
<DateTimePicker <DateTimePicker
id={`${uid}-expiryTime`}
aria-describedby={`${uid}-expiryTime-hint`}
value={wExpiry > 0 ? dayjs(wExpiry) : null} value={wExpiry > 0 ? dayjs(wExpiry) : null}
onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)} onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
/> />
<span id={`${uid}-expiryTime-hint`} hidden>
{t('pages.inbounds.leaveBlankToNeverExpire')}
</span>
</Form.Item> </Form.Item>
</> </>
); );
@@ -936,8 +912,9 @@ export default function InboundFormModal({
const streamTab = ( const streamTab = (
<> <>
{hasSelectableTransport && ( {hasSelectableTransport && (
<Form.Item label={t('transmission')}> <Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<Select <Select
id={`${uid}-network`}
style={{ width: '75%' }} style={{ width: '75%' }}
value={network} value={network}
onChange={onNetworkChange} onChange={onNetworkChange}
@@ -23,7 +23,7 @@ export default function AccountsList() {
}) })
} }
> >
<PlusOutlined /> {t('add')} <PlusOutlined aria-hidden /> {t('add')}
</Button> </Button>
</Form.Item> </Form.Item>
{fields.length > 0 && ( {fields.length > 0 && (
@@ -32,13 +32,13 @@ export default function AccountsList() {
<Space.Compact key={field.id} className="mb-8" block> <Space.Compact key={field.id} className="mb-8" block>
<InputAddon>{String(idx + 1)}</InputAddon> <InputAddon>{String(idx + 1)}</InputAddon>
<FormField name={['settings', 'accounts', idx, 'user']} noStyle> <FormField name={['settings', 'accounts', idx, 'user']} noStyle>
<Input placeholder={t('username')} /> <Input aria-label={`${t('username')} ${idx + 1}`} placeholder={t('username')} />
</FormField> </FormField>
<FormField name={['settings', 'accounts', idx, 'pass']} noStyle> <FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
<Input placeholder={t('password')} /> <Input aria-label={`${t('password')} ${idx + 1}`} placeholder={t('password')} />
</FormField> </FormField>
<Button aria-label={t('remove')} onClick={() => remove(idx)}> <Button aria-label={t('remove')} onClick={() => remove(idx)}>
<MinusOutlined /> <MinusOutlined aria-hidden />
</Button> </Button>
</Space.Compact> </Space.Compact>
))} ))}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
@@ -16,22 +17,23 @@ export default function AmneziawgFields({
regenInboundAwgObfuscation, regenInboundAwgObfuscation,
}: AmneziawgFieldsProps) { }: AmneziawgFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
return ( return (
<> <>
<Form.Item label={t('pages.xray.amneziawg.privateKey')}> <Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.xray.amneziawg.privateKey')}>
<Space.Compact block> <Space.Compact block>
<FormField name={['settings', 'server', 'privateKey']} noStyle> <FormField name={['settings', 'server', 'privateKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input id={`${uid}-privateKey`} style={{ width: 'calc(100% - 32px)' }} />
</FormField> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={regenInboundAwg} onClick={regenInboundAwg}
/> />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.amneziawg.publicKey')}> <Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.amneziawg.publicKey')}>
<Input value={awgPubKey} disabled /> <Input id={`${uid}-publicKey`} value={awgPubKey} disabled />
</Form.Item> </Form.Item>
<FormField <FormField
name={['settings', 'server', 'subnetIp']} name={['settings', 'server', 'subnetIp']}
@@ -89,7 +91,7 @@ export default function AmneziawgFields({
<Input placeholder="eth0" /> <Input placeholder="eth0" />
</FormField> </FormField>
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}> <Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}> <Button icon={<ReloadOutlined aria-hidden />} onClick={regenInboundAwgObfuscation}>
{t('pages.xray.amneziawg.regenerateObfuscation')} {t('pages.xray.amneziawg.regenerateObfuscation')}
</Button> </Button>
</Form.Item> </Form.Item>
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd'; import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
@@ -9,6 +10,7 @@ const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
export default function HysteriaFields() { export default function HysteriaFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext(); const { control, setValue } = useFormContext();
const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
| { type?: string } | { type?: string }
@@ -32,8 +34,9 @@ export default function HysteriaFields() {
<InputNumber min={2} max={600} style={{ width: '100%' }} /> <InputNumber min={2} max={600} style={{ width: '100%' }} />
</FormField> </FormField>
<Form.Item label={t('pages.inbounds.form.masquerade')}> <Form.Item htmlFor={`${uid}-masquerade`} label={t('pages.inbounds.form.masquerade')}>
<Switch <Switch
id={`${uid}-masquerade`}
checked={!!masq} checked={!!masq}
onChange={(checked) => onChange={(checked) =>
setValue( setValue(
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Space, Switch } from 'antd'; import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
@@ -13,6 +14,7 @@ interface ShadowsocksFieldsProps {
export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) { export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { getValues, setValue } = useFormContext(); const { getValues, setValue } = useFormContext();
return ( return (
<> <>
@@ -26,14 +28,14 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} /> <Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
</FormField> </FormField>
{isSSWith2022 && ( {isSSWith2022 && (
<Form.Item label={t('password')}> <Form.Item htmlFor={`${uid}-password`} label={t('password')}>
<Space.Compact block> <Space.Compact block>
<FormField name={['settings', 'password']} noStyle> <FormField name={['settings', 'password']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input id={`${uid}-password`} style={{ width: 'calc(100% - 32px)' }} />
</FormField> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={() => { onClick={() => {
const method = getValues('settings.method'); const method = getValues('settings.method');
setValue( setValue(
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AutoComplete, AutoComplete,
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
export default function TuicFields() { export default function TuicFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const { control, setValue } = useFormContext(); const { control, setValue } = useFormContext();
const [loadingPanelCert, setLoadingPanelCert] = useState(false); const [loadingPanelCert, setLoadingPanelCert] = useState(false);
@@ -147,8 +148,9 @@ export default function TuicFields() {
return ( return (
<> <>
{messageContextHolder} {messageContextHolder}
<Form.Item label={t('pages.inbounds.publicKey')}> <Form.Item htmlFor={`${uid}-certificate`} label={t('pages.inbounds.publicKey')}>
<AutoComplete <AutoComplete
id={`${uid}-certificate`}
value={certificate} value={certificate}
options={certOptions} options={certOptions}
onChange={(v) => setValue('settings.server.certificate', v)} onChange={(v) => setValue('settings.server.certificate', v)}
@@ -156,8 +158,9 @@ export default function TuicFields() {
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.privatekey')}> <Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.inbounds.privatekey')}>
<AutoComplete <AutoComplete
id={`${uid}-privateKey`}
value={privateKey} value={privateKey}
options={keyOptions} options={keyOptions}
onChange={(v) => setValue('settings.server.private_key', v)} onChange={(v) => setValue('settings.server.private_key', v)}
@@ -169,13 +172,13 @@ export default function TuicFields() {
<Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}> <Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
<Button <Button
type="primary" type="primary"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
loading={loadingPanelCert} loading={loadingPanelCert}
onClick={setCertFromPanel} onClick={setCertFromPanel}
> >
{t('pages.inbounds.setDefaultCert')} {t('pages.inbounds.setDefaultCert')}
</Button> </Button>
<Button icon={<SyncOutlined />} onClick={autofillFromSni}> <Button icon={<SyncOutlined aria-hidden />} onClick={autofillFromSni}>
{t('pages.inbounds.form.autoFill')} {t('pages.inbounds.form.autoFill')}
</Button> </Button>
<Button <Button
@@ -217,12 +220,21 @@ export default function TuicFields() {
<Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider> <Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider>
<Form.Item label={t('pages.xray.tuic.sni')} tooltip={t('pages.xray.tuic.sniHint')}> <Form.Item
htmlFor={`${uid}-sni`}
label={t('pages.xray.tuic.sni')}
tooltip={t('pages.xray.tuic.sniHint')}
>
<Input <Input
id={`${uid}-sni`}
aria-describedby={`${uid}-sni-hint`}
value={sni} value={sni}
placeholder="example.com" placeholder="example.com"
onChange={(e) => handleSniChange(e.target.value)} onChange={(e) => handleSniChange(e.target.value)}
/> />
<span id={`${uid}-sni-hint`} hidden>
{t('pages.xray.tuic.sniHint')}
</span>
</Form.Item> </Form.Item>
<FormField <FormField
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'; import { useId, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tooltip } from 'antd'; import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
@@ -9,28 +9,39 @@ import { FormField } from '@/components/form/rhf';
interface StringListProps { interface StringListProps {
name: string[]; name: string[];
label: ReactNode; label: ReactNode;
tooltip?: string;
placeholder: (index: number) => string; placeholder: (index: number) => string;
} }
function StringList({ name, label, placeholder }: StringListProps) { function StringList({ name, label, tooltip, placeholder }: StringListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const labelId = useId();
const { control } = useFormContext(); const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: name.join('.') }); const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
return ( return (
<Form.Item label={label}> <Form.Item label={<span id={labelId}>{label}</span>} tooltip={tooltip}>
<Button aria-label={t('add')} size="small" onClick={() => append('')}> <Button aria-label={t('add')} size="small" onClick={() => append('')}>
<PlusOutlined /> <PlusOutlined aria-hidden />
</Button> </Button>
{fields.map((field, j) => ( {fields.map((field, j) => (
<Space.Compact key={field.id} block className="mt-4"> <Space.Compact key={field.id} block className="mt-4">
<FormField name={[...name, j]} noStyle> <FormField name={[...name, j]} noStyle>
<Input placeholder={placeholder(j)} /> <Input
aria-labelledby={labelId}
aria-describedby={tooltip ? `${labelId}-hint` : undefined}
placeholder={placeholder(j)}
/>
</FormField> </FormField>
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}> <Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
<MinusOutlined /> <MinusOutlined aria-hidden />
</Button> </Button>
</Space.Compact> </Space.Compact>
))} ))}
{tooltip && (
<span id={`${labelId}-hint`} hidden>
{tooltip}
</span>
)}
</Form.Item> </Form.Item>
); );
} }
@@ -73,20 +84,14 @@ export default function TunFields() {
</FormField> </FormField>
<StringList <StringList
name={['settings', 'autoSystemRoutingTable']} name={['settings', 'autoSystemRoutingTable']}
label={ label={t('pages.inbounds.info.autoSystemRoutes')}
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}> tooltip={t('pages.inbounds.form.autoSystemRoutesTooltip')}
{t('pages.inbounds.info.autoSystemRoutes')}
</Tooltip>
}
placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')} placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
/> />
<FormField <FormField
name={['settings', 'autoOutboundsInterface']} name={['settings', 'autoOutboundsInterface']}
label={ label={t('pages.inbounds.form.autoOutboundsInterface')}
<Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}> tooltip={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}
{t('pages.inbounds.form.autoOutboundsInterface')}
</Tooltip>
}
> >
<Input placeholder="auto" /> <Input placeholder="auto" />
</FormField> </FormField>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd'; import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
@@ -26,6 +26,7 @@ export default function VlessFields({
clearVlessEnc, clearVlessEnc,
}: VlessFieldsProps) { }: VlessFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control } = useFormContext(); const { control } = useFormContext();
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519'); const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
@@ -47,9 +48,10 @@ export default function VlessFields({
<FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}> <FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
<Input /> <Input />
</FormField> </FormField>
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}> <Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap> <Space size={8} wrap>
<Select <Select
id={`${uid}-authKind`}
value={authKind} value={authKind}
onChange={(v) => setAuthKind(v)} onChange={(v) => setAuthKind(v)}
options={authOptions} options={authOptions}
@@ -80,6 +82,7 @@ export default function VlessFields({
defaultValue={def} defaultValue={def}
render={({ field }) => ( render={({ field }) => (
<InputNumber <InputNumber
aria-label={`${t('pages.inbounds.form.visionTestseed')} ${i + 1}`}
min={1} min={1}
style={{ width: '25%' }} style={{ width: '25%' }}
value={field.value as number} value={field.value as number}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
@@ -11,18 +12,23 @@ interface WireguardFieldsProps {
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) { export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
return ( return (
<> <>
<Form.Item label={t('pages.xray.wireguard.secretKey')}> <Form.Item htmlFor={`${uid}-secretKey`} label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block> <Space.Compact block>
<FormField name={['settings', 'secretKey']} noStyle> <FormField name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input id={`${uid}-secretKey`} style={{ width: 'calc(100% - 32px)' }} />
</FormField> </FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} /> <Button
aria-label={t('regenerate')}
icon={<ReloadOutlined aria-hidden />}
onClick={regenInboundWg}
/>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}> <Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.wireguard.publicKey')}>
<Input value={wgPubKey} disabled /> <Input id={`${uid}-publicKey`} value={wgPubKey} disabled />
</Form.Item> </Form.Item>
<FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}> <FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
<Input placeholder="10.0.0.0" /> <Input placeholder="10.0.0.0" />
@@ -199,6 +199,7 @@ export default function RealityTargetScannerModal({
{t('pages.inbounds.form.scanModalDesc')} {t('pages.inbounds.form.scanModalDesc')}
</Typography.Paragraph> </Typography.Paragraph>
<Input.Search <Input.Search
aria-label={t('pages.inbounds.form.scanDiscoverPlaceholder')}
allowClear allowClear
enterButton={t('pages.inbounds.form.scan')} enterButton={t('pages.inbounds.form.scan')}
loading={loading} loading={loading}
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -59,6 +59,7 @@ export default function RealityForm({
clearMldsa65, clearMldsa65,
}: RealityFormProps) { }: RealityFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { getFieldState, trigger } = useFormContext(); const { getFieldState, trigger } = useFormContext();
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' }); const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
@@ -113,6 +114,7 @@ export default function RealityForm({
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} /> <Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
</FormField> </FormField>
<Form.Item <Form.Item
htmlFor={`${uid}-target`}
label={t('pages.inbounds.form.target')} label={t('pages.inbounds.form.target')}
tooltip={t('pages.inbounds.form.realityTargetHint')} tooltip={t('pages.inbounds.form.realityTargetHint')}
> >
@@ -127,19 +129,27 @@ export default function RealityForm({
}, },
}} }}
> >
<Input style={{ flex: 1 }} placeholder="example.com:443" /> <Input
id={`${uid}-target`}
aria-describedby={`${uid}-target-hint`}
style={{ flex: 1 }}
placeholder="example.com:443"
/>
</FormField> </FormField>
<Button <Button
icon={<RadarChartOutlined />} icon={<RadarChartOutlined aria-hidden />}
loading={scanning} loading={scanning}
onClick={() => scanRealityTarget()} onClick={() => scanRealityTarget()}
> >
{t('pages.inbounds.form.scan')} {t('pages.inbounds.form.scan')}
</Button> </Button>
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}> <Button icon={<SearchOutlined aria-hidden />} onClick={() => setScannerOpen(true)}>
{t('pages.inbounds.form.findTargets')} {t('pages.inbounds.form.findTargets')}
</Button> </Button>
</Space.Compact> </Space.Compact>
<span id={`${uid}-target-hint`} hidden>
{t('pages.inbounds.form.realityTargetHint')}
</span>
</Form.Item> </Form.Item>
{scanResult && ( {scanResult && (
<Form.Item label=" " colon={false}> <Form.Item label=" " colon={false}>
@@ -235,32 +245,45 @@ export default function RealityForm({
> >
<Input placeholder="x.y.z" /> <Input placeholder="x.y.z" />
</FormField> </FormField>
<Form.Item label={t('pages.inbounds.form.shortIds')}> <Form.Item htmlFor={`${uid}-shortIds`} label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}> <Space.Compact block style={{ display: 'flex' }}>
<FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle> <FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} /> <Select
id={`${uid}-shortIds`}
mode="tags"
tokenSeparators={[',']}
style={{ flex: 1 }}
/>
</FormField> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={randomizeShortIds} onClick={randomizeShortIds}
/> />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
htmlFor={`${uid}-spiderX`}
label={t('pages.inbounds.form.spiderX')} label={t('pages.inbounds.form.spiderX')}
tooltip={t('pages.inbounds.form.spiderXHint')} tooltip={t('pages.inbounds.form.spiderXHint')}
> >
<Space.Compact block style={{ display: 'flex' }}> <Space.Compact block style={{ display: 'flex' }}>
<FormField name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} noStyle> <FormField name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} noStyle>
<Input style={{ flex: 1 }} /> <Input
id={`${uid}-spiderX`}
aria-describedby={`${uid}-spiderX-hint`}
style={{ flex: 1 }}
/>
</FormField> </FormField>
<Button <Button
aria-label={t('regenerate')} aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined aria-hidden />}
onClick={randomizeSpiderX} onClick={randomizeSpiderX}
/> />
</Space.Compact> </Space.Compact>
<span id={`${uid}-spiderX-hint`} hidden>
{t('pages.inbounds.form.spiderXHint')}
</span>
</Form.Item> </Form.Item>
<FormField <FormField
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']} name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd'; import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import { import {
@@ -77,7 +78,7 @@ function CertRow({
{total > 1 && ( {total > 1 && (
<Form.Item label=" "> <Form.Item label=" ">
<Button size="small" danger onClick={onRemove}> <Button size="small" danger onClick={onRemove}>
<MinusOutlined /> {t('remove')} <MinusOutlined aria-hidden /> {t('remove')}
</Button> </Button>
</Form.Item> </Form.Item>
)} )}
@@ -161,16 +162,20 @@ function CertRow({
function EchSockoptSection() { function EchSockoptSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext(); const { control, setValue } = useFormContext();
const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' }); const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
const on = !!echSockopt; const on = !!echSockopt;
return ( return (
<> <>
<Form.Item <Form.Item
htmlFor={`${uid}-echSockopt`}
label={t('pages.inbounds.form.echSockopt')} label={t('pages.inbounds.form.echSockopt')}
tooltip={t('pages.inbounds.form.echSockoptTip')} tooltip={t('pages.inbounds.form.echSockoptTip')}
> >
<Switch <Switch
id={`${uid}-echSockopt`}
aria-describedby={`${uid}-echSockopt-hint`}
checked={on} checked={on}
onChange={(v) => onChange={(v) =>
setValue( setValue(
@@ -179,6 +184,9 @@ function EchSockoptSection() {
) )
} }
/> />
<span id={`${uid}-echSockopt-hint`} hidden>
{t('pages.inbounds.form.echSockoptTip')}
</span>
</Form.Item> </Form.Item>
{on && ( {on && (
<> <>
@@ -226,6 +234,7 @@ export default function TlsForm({
clearEchCert, clearEchCert,
}: TlsFormProps) { }: TlsFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control } = useFormContext(); const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
@@ -242,16 +251,18 @@ export default function TlsForm({
> >
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} /> <CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
</FormField> </FormField>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}> <Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block> <Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select <Select
id={`${uid}-minVersion`}
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/> />
</FormField> </FormField>
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle> <FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select <Select
aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }} style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))} options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/> />
@@ -330,7 +341,7 @@ export default function TlsForm({
}) })
} }
> >
<PlusOutlined /> <PlusOutlined aria-hidden />
</Button> </Button>
</Form.Item> </Form.Item>
{fields.map((field, idx) => ( {fields.map((field, idx) => (
@@ -375,6 +386,7 @@ export default function TlsForm({
</Space> </Space>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
htmlFor={`${uid}-pinnedPeerCertSha256`}
label={t('pages.inbounds.form.pinnedPeerCertSha256')} label={t('pages.inbounds.form.pinnedPeerCertSha256')}
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')} tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
> >
@@ -384,6 +396,8 @@ export default function TlsForm({
noStyle noStyle
> >
<Select <Select
id={`${uid}-pinnedPeerCertSha256`}
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
mode="tags" mode="tags"
tokenSeparators={[',', ' ']} tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')} placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
@@ -391,18 +405,23 @@ export default function TlsForm({
/> />
</FormField> </FormField>
<Button <Button
icon={<FileProtectOutlined />} icon={<FileProtectOutlined aria-hidden />}
onClick={pinFromCert} onClick={pinFromCert}
loading={saving} loading={saving}
aria-label={t('pages.inbounds.form.pinFromCert')}
title={t('pages.inbounds.form.pinFromCert')} title={t('pages.inbounds.form.pinFromCert')}
/> />
<Button <Button
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
onClick={() => pinFromRemote()} onClick={() => pinFromRemote()}
loading={saving} loading={saving}
aria-label={t('pages.inbounds.form.pinFromRemote')}
title={t('pages.inbounds.form.pinFromRemote')} title={t('pages.inbounds.form.pinFromRemote')}
/> />
</Space.Compact> </Space.Compact>
<span id={`${uid}-pinnedPeerCertSha256-hint`} hidden>
{t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
</span>
</Form.Item> </Form.Item>
<FormField <FormField
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']} name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd'; import { Form, Input, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
@@ -7,6 +8,7 @@ import { FormField } from '@/components/form/rhf';
export default function RawForm() { export default function RawForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext(); const { control, setValue } = useFormContext();
const headerType = (useWatch({ const headerType = (useWatch({
control, control,
@@ -21,8 +23,9 @@ export default function RawForm() {
> >
<Switch /> <Switch />
</FormField> </FormField>
<Form.Item label={`HTTP ${t('camouflage')}`}> <Form.Item htmlFor={`${uid}-httpCamouflage`} label={`HTTP ${t('camouflage')}`}>
<Switch <Switch
id={`${uid}-httpCamouflage`}
checked={headerType === 'http'} checked={headerType === 'http'}
onChange={(v) => { onChange={(v) => {
setValue( setValue(
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd'; import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
@@ -28,6 +29,7 @@ export default function SockoptForm({
network: string; network: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { control, getValues, setValue } = useFormContext(); const { control, getValues, setValue } = useFormContext();
const sock = useWatch({ control, name: 'streamSettings.sockopt' }); const sock = useWatch({ control, name: 'streamSettings.sockopt' });
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
@@ -101,6 +103,8 @@ export default function SockoptForm({
tooltip={t('pages.inbounds.form.realClientIpHint')} tooltip={t('pages.inbounds.form.realClientIpHint')}
> >
<Segmented <Segmented
aria-label={t('pages.inbounds.form.realClientIp')}
aria-describedby={`${uid}-real-client-ip-hint`}
value={presetValue} value={presetValue}
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)} onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
options={[ options={[
@@ -112,6 +116,9 @@ export default function SockoptForm({
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') }, { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]} ]}
/> />
<span id={`${uid}-real-client-ip-hint`} hidden>
{t('pages.inbounds.form.realClientIpHint')}
</span>
</Form.Item> </Form.Item>
{trustedMismatch && ( {trustedMismatch && (
<Alert <Alert
@@ -533,7 +533,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(clientSettings.tgId, t)} onClick={() => copyText(clientSettings.tgId, t)}
/> />
@@ -552,7 +552,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(link.link, t)} onClick={() => copyText(link.link, t)}
/> />
@@ -573,7 +573,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(subLink, t)} onClick={() => copyText(subLink, t)}
/> />
@@ -595,7 +595,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(subJsonLink, t)} onClick={() => copyText(subJsonLink, t)}
/> />
@@ -714,7 +714,7 @@ export default function InboundInfoModal({
<Button <Button
size="small" size="small"
className="value-copy" className="value-copy"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(encryptionLabel, t)} onClick={() => copyText(encryptionLabel, t)}
/> />
@@ -984,7 +984,7 @@ export default function InboundInfoModal({
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(`${account.user}:${account.pass}`, t)} onClick={() => copyText(`${account.user}:${account.pass}`, t)}
/> />
@@ -1100,7 +1100,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(`${account.user}:${account.pass}`, t)} onClick={() => copyText(`${account.user}:${account.pass}`, t)}
/> />
@@ -1201,7 +1201,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(cfg, t)} onClick={() => copyText(cfg, t)}
/> />
@@ -1209,7 +1209,7 @@ export default function InboundInfoModal({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
onClick={() => onClick={() =>
downloadText(cfg, peerConfFileName(idx, j, all.length)) downloadText(cfg, peerConfFileName(idx, j, all.length))
@@ -1230,7 +1230,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(link, t)} onClick={() => copyText(link, t)}
/> />
@@ -1261,7 +1261,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(cfg, t)} onClick={() => copyText(cfg, t)}
/> />
@@ -1269,7 +1269,7 @@ export default function InboundInfoModal({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
onClick={() => downloadText(cfg, peerConfFileName(idx, j, all.length))} onClick={() => downloadText(cfg, peerConfFileName(idx, j, all.length))}
/> />
@@ -1288,7 +1288,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button <Button
size="small" size="small"
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')} aria-label={t('copy')}
onClick={() => copyText(link, t)} onClick={() => copyText(link, t)}
/> />
@@ -193,20 +193,24 @@ export default function InboundList({
const generalActionsMenu: MenuProps = { const generalActionsMenu: MenuProps = {
items: [ items: [
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') }, {
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') }, key: 'import',
icon: <ImportOutlined aria-hidden />,
label: t('pages.inbounds.importInbound'),
},
{ key: 'export', icon: <ExportOutlined aria-hidden />, label: t('pages.inbounds.export') },
...(subEnable ...(subEnable
? [ ? [
{ {
key: 'subs', key: 'subs',
icon: <ExportOutlined />, icon: <ExportOutlined aria-hidden />,
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
}, },
] ]
: []), : []),
{ {
key: 'resetInbounds', key: 'resetInbounds',
icon: <ReloadOutlined />, icon: <ReloadOutlined aria-hidden />,
label: t('pages.inbounds.resetAllTraffic'), label: t('pages.inbounds.resetAllTraffic'),
}, },
], ],
@@ -221,7 +225,7 @@ export default function InboundList({
<Button <Button
type="primary" type="primary"
onClick={onAddInbound} onClick={onAddInbound}
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('pages.inbounds.addInbound')} aria-label={t('pages.inbounds.addInbound')}
> >
{!isMobile && t('pages.inbounds.addInbound')} {!isMobile && t('pages.inbounds.addInbound')}
@@ -229,7 +233,7 @@ export default function InboundList({
<Dropdown trigger={['click']} menu={generalActionsMenu}> <Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button <Button
type="primary" type="primary"
icon={<MenuOutlined />} icon={<MenuOutlined aria-hidden />}
aria-label={t('pages.inbounds.generalActions')} aria-label={t('pages.inbounds.generalActions')}
> >
{!isMobile && t('pages.inbounds.generalActions')} {!isMobile && t('pages.inbounds.generalActions')}
@@ -251,7 +255,7 @@ export default function InboundList({
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('search')} placeholder={t('search')}
allowClear allowClear
prefix={<SearchOutlined />} prefix={<SearchOutlined aria-hidden />}
style={{ maxWidth: isMobile ? 110 : 200 }} style={{ maxWidth: isMobile ? 110 : 200 }}
aria-label={t('search')} aria-label={t('search')}
/> />
@@ -267,7 +271,7 @@ export default function InboundList({
</Tag> </Tag>
<Button <Button
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={handleBulkDelete} onClick={handleBulkDelete}
aria-label={t('delete')} aria-label={t('delete')}
> >
@@ -283,7 +287,7 @@ export default function InboundList({
<div className="inbound-cards"> <div className="inbound-cards">
{visibleInbounds.length === 0 ? ( {visibleInbounds.length === 0 ? (
<div className="card-empty"> <div className="card-empty">
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} /> <ImportOutlined aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
) : ( ) : (
@@ -327,6 +331,7 @@ export default function InboundList({
/> />
</Tooltip> </Tooltip>
<Switch <Switch
aria-label={t('pages.inbounds.enable')}
checked={record.enable} checked={record.enable}
size="small" size="small"
onChange={(next) => onSwitchEnable(record, next)} onChange={(next) => onSwitchEnable(record, next)}
@@ -350,7 +355,7 @@ export default function InboundList({
type="text" type="text"
size="small" size="small"
className="row-action-trigger" className="row-action-trigger"
icon={<MoreOutlined />} icon={<MoreOutlined aria-hidden />}
aria-label={t('more')} aria-label={t('more')}
/> />
</Dropdown> </Dropdown>
@@ -377,7 +382,7 @@ export default function InboundList({
locale={{ locale={{
emptyText: ( emptyText: (
<div className="card-empty"> <div className="card-empty">
<ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <ImportOutlined aria-hidden style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
), ),
+28 -15
View File
@@ -40,69 +40,82 @@ export function buildRowActionsMenu({
}): MenuProps['items'] { }): MenuProps['items'] {
const items: MenuProps['items'] = []; const items: MenuProps['items'] = [];
if (isMobile) { if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') }); items.push({ key: 'edit', icon: <EditOutlined aria-hidden />, label: t('edit') });
} }
if (isInboundMultiUser(record)) { if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') }); items.push({
key: 'export',
icon: <ExportOutlined aria-hidden />,
label: t('pages.inbounds.export'),
});
if (subEnable) { if (subEnable) {
items.push({ items.push({
key: 'subs', key: 'subs',
icon: <ExportOutlined />, icon: <ExportOutlined aria-hidden />,
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
}); });
} }
} else { } else {
items.push({ items.push({
key: 'showInfo', key: 'showInfo',
icon: <InfoCircleOutlined />, icon: <InfoCircleOutlined aria-hidden />,
label: t('pages.inbounds.inboundInfo'), label: t('pages.inbounds.inboundInfo'),
}); });
} }
items.push({ items.push({
key: 'clipboard', key: 'clipboard',
icon: <CopyOutlined />, icon: <CopyOutlined aria-hidden />,
label: t('pages.inbounds.exportInbound'), label: t('pages.inbounds.exportInbound'),
}); });
items.push({ items.push({
key: 'resetTraffic', key: 'resetTraffic',
icon: <RetweetOutlined />, icon: <RetweetOutlined aria-hidden />,
label: t('pages.inbounds.resetTraffic'), label: t('pages.inbounds.resetTraffic'),
}); });
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') }); items.push({
key: 'clone',
icon: <BlockOutlined aria-hidden />,
label: t('pages.inbounds.clone'),
});
if (isInboundMultiUser(record)) { if (isInboundMultiUser(record)) {
items.push({ items.push({
key: 'attachExisting', key: 'attachExisting',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.inbounds.attachExistingClients'), label: t('pages.inbounds.attachExistingClients'),
}); });
} }
if (isInboundMultiUser(record) && hasClients) { if (isInboundMultiUser(record) && hasClients) {
items.push({ items.push({
key: 'attachClients', key: 'attachClients',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.inbounds.attachClients'), label: t('pages.inbounds.attachClients'),
}); });
items.push({ items.push({
key: 'detachClients', key: 'detachClients',
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined aria-hidden />,
label: t('pages.inbounds.detachClients'), label: t('pages.inbounds.detachClients'),
}); });
items.push({ items.push({
key: 'addToGroup', key: 'addToGroup',
icon: <TagsOutlined />, icon: <TagsOutlined aria-hidden />,
label: t('pages.inbounds.addClientsToGroup'), label: t('pages.inbounds.addClientsToGroup'),
}); });
items.push({ type: 'divider' }); items.push({ type: 'divider' });
items.push({ items.push({
key: 'delAllClients', key: 'delAllClients',
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined aria-hidden />,
danger: true, danger: true,
label: t('pages.inbounds.delAllClients'), label: t('pages.inbounds.delAllClients'),
}); });
} else { } else {
items.push({ type: 'divider' }); items.push({ type: 'divider' });
} }
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') }); items.push({
key: 'delete',
icon: <DeleteOutlined aria-hidden />,
danger: true,
label: t('delete'),
});
return items; return items;
} }
@@ -114,7 +127,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
aria-label={t('edit')} aria-label={t('edit')}
onClick={() => onClick('edit')} onClick={() => onClick('edit')}
/> />
@@ -129,7 +142,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<MoreOutlined />} icon={<MoreOutlined aria-hidden />}
aria-label={t('more')} aria-label={t('more')}
/> />
</Dropdown> </Dropdown>
@@ -130,7 +130,11 @@ export function useInboundColumns({
align: 'center', align: 'center',
width: 80, width: 80,
render: (_, record) => ( render: (_, record) => (
<Switch checked={record.enable} onChange={(next) => onSwitchEnable(record, next)} /> <Switch
aria-label={t('pages.inbounds.enable')}
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
), ),
}, },
]; ];
@@ -293,7 +297,7 @@ export function useInboundColumns({
className="client-count-tag" className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }} style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
> >
<TeamOutlined /> {cc.clients} <TeamOutlined aria-hidden /> {cc.clients}
</Tag> </Tag>
{cc.active.length > 0 ? ( {cc.active.length > 0 ? (
<Popover <Popover
+8 -3
View File
@@ -102,13 +102,18 @@ export default function QrPanel({
{remark} {remark}
</Tag> </Tag>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} /> <Button
size="small"
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={copy}
/>
</Tooltip> </Tooltip>
{showQr && ( {showQr && (
<Tooltip title={t('downloadImage')}> <Tooltip title={t('downloadImage')}>
<Button <Button
size="small" size="small"
icon={<PictureOutlined />} icon={<PictureOutlined aria-hidden />}
aria-label={t('downloadImage')} aria-label={t('downloadImage')}
onClick={downloadImage} onClick={downloadImage}
/> />
@@ -118,7 +123,7 @@ export default function QrPanel({
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
onClick={download} onClick={download}
/> />
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd'; import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons'; import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
@@ -30,6 +30,7 @@ function shortTime(value?: number): string {
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) { export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('50'); const [rows, setRows] = useState('50');
@@ -120,6 +121,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Form layout="inline" className="log-toolbar"> <Form layout="inline" className="log-toolbar">
<Form.Item> <Form.Item>
<Select <Select
aria-label={t('pages.index.logLines')}
value={rows} value={rows}
size="small" size="small"
style={{ width: 100 }} style={{ width: 100 }}
@@ -132,8 +134,9 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('filter')} className="filter-item"> <Form.Item htmlFor={`${uid}-filter`} label={t('filter')} className="filter-item">
<Input <Input
id={`${uid}-filter`}
value={filter} value={filter}
size="small" size="small"
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
@@ -151,7 +154,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Button <Button
type="primary" type="primary"
onClick={download} onClick={download}
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
/> />
</Form.Item> </Form.Item>
+3 -3
View File
@@ -88,7 +88,7 @@ export default function BackupModal({
type="primary" type="primary"
aria-label={t('pages.index.exportDatabase')} aria-label={t('pages.index.exportDatabase')}
onClick={exportDb} onClick={exportDb}
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
/> />
</div> </div>
@@ -102,7 +102,7 @@ export default function BackupModal({
type="primary" type="primary"
aria-label={t('pages.index.migrationDownload')} aria-label={t('pages.index.migrationDownload')}
onClick={exportMigration} onClick={exportMigration}
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
/> />
</div> </div>
)} )}
@@ -120,7 +120,7 @@ export default function BackupModal({
type="primary" type="primary"
aria-label={t('pages.index.importDatabase')} aria-label={t('pages.index.importDatabase')}
onClick={importDb} onClick={importDb}
icon={<UploadOutlined />} icon={<UploadOutlined aria-hidden />}
/> />
</div> </div>
+18 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd'; import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
@@ -33,6 +33,7 @@ function fileNameFromUrl(url: string): string {
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) { export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -180,15 +181,25 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
<Spin spinning={loading}> <Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon /> <Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}> <Form.Item
htmlFor={`${uid}-cron`}
label={t('pages.index.geodataCron')}
style={{ marginBottom: 8 }}
>
<Input <Input
id={`${uid}-cron`}
value={cron} value={cron}
placeholder={DEFAULT_CRON} placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)} onChange={(e) => setCron(e.target.value)}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}> <Form.Item
htmlFor={`${uid}-outbound`}
label={t('pages.index.geodataOutbound')}
style={{ marginBottom: 8 }}
>
<Select <Select
id={`${uid}-outbound`}
style={{ width: '100%' }} style={{ width: '100%' }}
allowClear allowClear
value={outbound} value={outbound}
@@ -204,6 +215,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
{rows.map((row, i) => ( {rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}> <Space.Compact key={i} style={{ width: '100%' }}>
<Input <Input
aria-label={`URL ${i + 1}`}
style={{ width: '60%' }} style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat" placeholder="https://example.com/geosite_custom.dat"
value={row.url} value={row.url}
@@ -211,6 +223,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
onBlur={() => onUrlBlur(i)} onBlur={() => onUrlBlur(i)}
/> />
<Input <Input
aria-label={`${t('pages.index.geodataFile')} ${i + 1}`}
style={{ width: '40%' }} style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')} placeholder={t('pages.index.geodataFile')}
value={row.file} value={row.file}
@@ -218,14 +231,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
/> />
<Button <Button
aria-label={t('delete')} aria-label={t('delete')}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))} onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/> />
</Space.Compact> </Space.Compact>
))} ))}
<div className="actions-row"> <div className="actions-row">
<Button <Button
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])} onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
> >
{t('pages.index.geodataAddFile')} {t('pages.index.geodataAddFile')}
+6 -6
View File
@@ -227,7 +227,7 @@ export default function IndexPage() {
<div className="ov-vitals"> <div className="ov-vitals">
<VitalTile <VitalTile
icon={<DashboardOutlined />} icon={<DashboardOutlined aria-hidden />}
label={t('pages.index.cpu')} label={t('pages.index.cpu')}
percent={status.cpu.percent} percent={status.cpu.percent}
statusColor={status.cpu.color} statusColor={status.cpu.color}
@@ -238,7 +238,7 @@ export default function IndexPage() {
isMobile={isMobile} isMobile={isMobile}
/> />
<VitalTile <VitalTile
icon={<DatabaseOutlined />} icon={<DatabaseOutlined aria-hidden />}
label={t('pages.index.memory')} label={t('pages.index.memory')}
percent={status.mem.percent} percent={status.mem.percent}
statusColor={status.mem.color} statusColor={status.mem.color}
@@ -249,7 +249,7 @@ export default function IndexPage() {
isMobile={isMobile} isMobile={isMobile}
/> />
<VitalTile <VitalTile
icon={<SwapOutlined />} icon={<SwapOutlined aria-hidden />}
label={t('pages.index.swap')} label={t('pages.index.swap')}
percent={status.swap.percent} percent={status.swap.percent}
statusColor={status.swap.color} statusColor={status.swap.color}
@@ -260,7 +260,7 @@ export default function IndexPage() {
isMobile={isMobile} isMobile={isMobile}
/> />
<VitalTile <VitalTile
icon={<HddOutlined />} icon={<HddOutlined aria-hidden />}
label={t('pages.index.storage')} label={t('pages.index.storage')}
percent={status.disk.percent} percent={status.disk.percent}
statusColor={status.disk.color} statusColor={status.disk.color}
@@ -358,7 +358,7 @@ export default function IndexPage() {
key="download" key="download"
onClick={downloadConfig} onClick={downloadConfig}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
> >
{isMobile ? 'Download' : 'config.json'} {isMobile ? 'Download' : 'config.json'}
</Button>, </Button>,
@@ -367,7 +367,7 @@ export default function IndexPage() {
type="primary" type="primary"
onClick={copyConfig} onClick={copyConfig}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
> >
Copy Copy
</Button>, </Button>,
+3 -1
View File
@@ -105,6 +105,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Form.Item> <Form.Item>
<Space.Compact> <Space.Compact>
<Select <Select
aria-label={t('pages.index.logLines')}
value={rows} value={rows}
size="small" size="small"
style={{ width: 100 }} style={{ width: 100 }}
@@ -118,6 +119,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
]} ]}
/> />
<Select <Select
aria-label={t('pages.xray.logLevel')}
value={level} value={level}
size="small" size="small"
style={{ minWidth: 95 }} style={{ minWidth: 95 }}
@@ -144,7 +146,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Button <Button
type="primary" type="primary"
onClick={download} onClick={download}
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
/> />
</Form.Item> </Form.Item>
+19 -10
View File
@@ -16,6 +16,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version'; import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status'; import type { Status } from '@/models/status';
interface OverviewActionBarProps { interface OverviewActionBarProps {
@@ -80,25 +81,30 @@ export default function OverviewActionBar({
[ [
{ {
key: 'restart', key: 'restart',
icon: <ReloadOutlined />, icon: <ReloadOutlined aria-hidden />,
text: t('pages.index.restartXray'), text: t('pages.index.restartXray'),
onClick: onRestartXray, onClick: onRestartXray,
primary: true, primary: true,
}, },
{ {
key: 'stop', key: 'stop',
icon: <PoweroffOutlined />, icon: <PoweroffOutlined aria-hidden />,
text: t('pages.index.stopXray'), text: t('pages.index.stopXray'),
onClick: onStopXray, onClick: onStopXray,
}, },
], ],
[ [
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs }, {
key: 'logs',
icon: <BarsOutlined aria-hidden />,
text: t('pages.index.logs'),
onClick: onOpenLogs,
},
...(accessLogEnable ...(accessLogEnable
? [ ? [
{ {
key: 'accessLogs', key: 'accessLogs',
icon: <FileTextOutlined />, icon: <FileTextOutlined aria-hidden />,
text: t('pages.index.accessLogs'), text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs, onClick: onOpenXrayLogs,
}, },
@@ -108,7 +114,7 @@ export default function OverviewActionBar({
? [ ? [
{ {
key: 'amneziawgLogs', key: 'amneziawgLogs',
icon: <ApiOutlined />, icon: <ApiOutlined aria-hidden />,
text: t('pages.index.amneziawgLogs'), text: t('pages.index.amneziawgLogs'),
onClick: onOpenAmneziaWGLogs, onClick: onOpenAmneziaWGLogs,
}, },
@@ -116,13 +122,13 @@ export default function OverviewActionBar({
: []), : []),
{ {
key: 'config', key: 'config',
icon: <ControlOutlined />, icon: <ControlOutlined aria-hidden />,
text: t('pages.index.config'), text: t('pages.index.config'),
onClick: onOpenConfig, onClick: onOpenConfig,
}, },
{ {
key: 'backup', key: 'backup',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
text: t('pages.index.backupTitle'), text: t('pages.index.backupTitle'),
onClick: onOpenBackup, onClick: onOpenBackup,
}, },
@@ -130,13 +136,13 @@ export default function OverviewActionBar({
[ [
{ {
key: 'history', key: 'history',
icon: <AreaChartOutlined />, icon: <AreaChartOutlined aria-hidden />,
text: t('pages.index.systemHistoryTitle'), text: t('pages.index.systemHistoryTitle'),
onClick: onOpenSystemHistory, onClick: onOpenSystemHistory,
}, },
{ {
key: 'metrics', key: 'metrics',
icon: <ArrowUpOutlined />, icon: <ArrowUpOutlined aria-hidden />,
text: t('pages.index.xrayMetricsTitle'), text: t('pages.index.xrayMetricsTitle'),
onClick: onOpenXrayMetrics, onClick: onOpenXrayMetrics,
}, },
@@ -177,8 +183,11 @@ export default function OverviewActionBar({
<Tag <Tag
className="ov-update-tag" className="ov-update-tag"
color="warning" color="warning"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
role="button"
tabIndex={0}
onClick={onOpenPanelUpdate} onClick={onOpenPanelUpdate}
onKeyDown={activateOnKey(onOpenPanelUpdate)}
> >
{`${t('update')} ${formatPanelVersion(latestVersion)}`} {`${t('update')} ${formatPanelVersion(latestVersion)}`}
</Tag> </Tag>
@@ -144,7 +144,12 @@ export default function PanelUpdateModal({
<div className="version-list"> <div className="version-list">
<div className="version-list-item"> <div className="version-list-item">
<span>{t('pages.index.devChannel')}</span> <span>{t('pages.index.devChannel')}</span>
<Switch checked={!!devChannelEnable} loading={channelBusy} onChange={handleChannel} /> <Switch
aria-label={t('pages.index.devChannel')}
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div> </div>
</div> </div>
@@ -190,7 +195,7 @@ export default function PanelUpdateModal({
type="primary" type="primary"
disabled={!info.updateAvailable} disabled={!info.updateAvailable}
onClick={updatePanel} onClick={updatePanel}
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
> >
{t('pages.index.updatePanel')} {t('pages.index.updatePanel')}
</Button> </Button>
@@ -50,7 +50,7 @@ const METRICS: MetricDef[] = [
tab: 'CPU', tab: 'CPU',
tabKey: 'pages.index.cpu', tabKey: 'pages.index.cpu',
title: 'pages.index.historyTitleCpu', title: 'pages.index.historyTitleCpu',
icon: <DashboardOutlined />, icon: <DashboardOutlined aria-hidden />,
valueMax: 100, valueMax: 100,
unit: '%', unit: '%',
stroke: '', stroke: '',
@@ -60,7 +60,7 @@ const METRICS: MetricDef[] = [
tab: 'RAM', tab: 'RAM',
tabKey: 'pages.index.memory', tabKey: 'pages.index.memory',
title: 'pages.index.historyTitleMem', title: 'pages.index.historyTitleMem',
icon: <DatabaseOutlined />, icon: <DatabaseOutlined aria-hidden />,
valueMax: 100, valueMax: 100,
unit: '%', unit: '%',
stroke: '#7c4dff', stroke: '#7c4dff',
@@ -74,7 +74,7 @@ const METRICS: MetricDef[] = [
tab: 'Bandwidth', tab: 'Bandwidth',
tabKey: 'pages.index.historyTabBandwidth', tabKey: 'pages.index.historyTabBandwidth',
title: 'pages.index.historyTitleNetwork', title: 'pages.index.historyTitleNetwork',
icon: <GlobalOutlined />, icon: <GlobalOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: 'B/s', unit: 'B/s',
stroke: '#1890ff', stroke: '#1890ff',
@@ -88,7 +88,7 @@ const METRICS: MetricDef[] = [
tab: 'Packets', tab: 'Packets',
tabKey: 'pages.index.historyTabPackets', tabKey: 'pages.index.historyTabPackets',
title: 'pages.index.historyTitlePackets', title: 'pages.index.historyTitlePackets',
icon: <DeploymentUnitOutlined />, icon: <DeploymentUnitOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: 'pkt/s', unit: 'pkt/s',
stroke: '#2f54eb', stroke: '#2f54eb',
@@ -102,7 +102,7 @@ const METRICS: MetricDef[] = [
tab: 'Connections', tab: 'Connections',
tabKey: 'pages.index.historyTabConnections', tabKey: 'pages.index.historyTabConnections',
title: 'pages.index.historyTitleConnections', title: 'pages.index.historyTitleConnections',
icon: <ApiOutlined />, icon: <ApiOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: '', unit: '',
stroke: '#597ef7', stroke: '#597ef7',
@@ -116,7 +116,7 @@ const METRICS: MetricDef[] = [
tab: 'Disk I/O', tab: 'Disk I/O',
tabKey: 'pages.index.historyTabDisk', tabKey: 'pages.index.historyTabDisk',
title: 'pages.index.historyTitleDisk', title: 'pages.index.historyTitleDisk',
icon: <HddOutlined />, icon: <HddOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: 'B/s', unit: 'B/s',
stroke: '#eb2f96', stroke: '#eb2f96',
@@ -130,7 +130,7 @@ const METRICS: MetricDef[] = [
tab: 'Disk Usage', tab: 'Disk Usage',
tabKey: 'pages.index.historyTabDiskUsage', tabKey: 'pages.index.historyTabDiskUsage',
title: 'pages.index.historyTitleDiskUsage', title: 'pages.index.historyTitleDiskUsage',
icon: <PieChartOutlined />, icon: <PieChartOutlined aria-hidden />,
valueMax: 100, valueMax: 100,
unit: '%', unit: '%',
stroke: '#13c2c2', stroke: '#13c2c2',
@@ -140,7 +140,7 @@ const METRICS: MetricDef[] = [
tab: 'Online', tab: 'Online',
tabKey: 'pages.index.historyTabOnline', tabKey: 'pages.index.historyTabOnline',
title: 'pages.index.historyTitleOnline', title: 'pages.index.historyTitleOnline',
icon: <TeamOutlined />, icon: <TeamOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: '', unit: '',
stroke: '#52c41a', stroke: '#52c41a',
@@ -150,7 +150,7 @@ const METRICS: MetricDef[] = [
tab: 'Load', tab: 'Load',
tabKey: 'pages.index.historyTabLoad', tabKey: 'pages.index.historyTabLoad',
title: 'pages.index.historyTitleLoad', title: 'pages.index.historyTitleLoad',
icon: <LineChartOutlined />, icon: <LineChartOutlined aria-hidden />,
valueMax: null, valueMax: null,
unit: '', unit: '',
stroke: '#fa8c16', stroke: '#fa8c16',
@@ -334,6 +334,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
<div className="metric-modal-title"> <div className="metric-modal-title">
<span>{t('pages.index.systemHistoryTitle')}</span> <span>{t('pages.index.systemHistoryTitle')}</span>
<Select <Select
aria-label={t('pages.index.timeRange')}
value={bucket} value={bucket}
size="small" size="small"
className="bucket-select" className="bucket-select"
+3 -3
View File
@@ -26,7 +26,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-grid"> <div className="ov-strip-grid">
<div className="ov-strip-cell"> <div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon"> <div className="ov-kicker ov-kicker-icon">
<ClockCircleOutlined /> <ClockCircleOutlined aria-hidden />
{t('pages.index.uptime')} {t('pages.index.uptime')}
</div> </div>
<div className="ov-strip-split"> <div className="ov-strip-split">
@@ -46,7 +46,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-cell"> <div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon"> <div className="ov-kicker ov-kicker-icon">
<DatabaseOutlined /> <DatabaseOutlined aria-hidden />
{t('pages.index.panel')} {t('pages.index.panel')}
</div> </div>
<div className="ov-strip-split"> <div className="ov-strip-split">
@@ -64,7 +64,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-cell"> <div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon"> <div className="ov-kicker ov-kicker-icon">
<GlobalOutlined /> <GlobalOutlined aria-hidden />
{t('pages.index.ipAddresses')} {t('pages.index.ipAddresses')}
<Tooltip title={t('pages.index.toggleIpVisibility')}> <Tooltip title={t('pages.index.toggleIpVisibility')}>
{showIp ? ( {showIp ? (
+2 -2
View File
@@ -47,12 +47,12 @@ export default function ThroughputCard({
</div> </div>
<div className="ov-wide-legend"> <div className="ov-wide-legend">
<div className="ov-legend-label"> <div className="ov-legend-label">
<ArrowUpOutlined style={{ color: accent }} /> <ArrowUpOutlined aria-hidden style={{ color: accent }} />
{t('pages.index.upload')} {t('pages.index.upload')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span> <span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
</div> </div>
<div className="ov-legend-label"> <div className="ov-legend-label">
<ArrowDownOutlined style={{ color: downColor }} /> <ArrowDownOutlined aria-hidden style={{ color: downColor }} />
{t('pages.index.download')} {t('pages.index.download')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span> <span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
</div> </div>
+6 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd'; import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
@@ -63,6 +63,7 @@ const AUTO_UPDATE_INTERVAL = 5000;
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) { export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20'); const [rows, setRows] = useState('20');
@@ -173,6 +174,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Form layout="inline" className="log-toolbar"> <Form layout="inline" className="log-toolbar">
<Form.Item> <Form.Item>
<Select <Select
aria-label={t('pages.index.logLines')}
value={rows} value={rows}
size="small" size="small"
style={{ width: 100 }} style={{ width: 100 }}
@@ -186,8 +188,9 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('filter')} className="filter-item"> <Form.Item htmlFor={`${uid}-filter`} label={t('filter')} className="filter-item">
<Input <Input
id={`${uid}-filter`}
value={filter} value={filter}
size="small" size="small"
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
@@ -214,7 +217,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Button <Button
type="primary" type="primary"
onClick={download} onClick={download}
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
/> />
</Form.Item> </Form.Item>
@@ -53,7 +53,7 @@ const METRICS: MetricDef[] = [
tab: 'Heap', tab: 'Heap',
tabKey: 'pages.index.xrayTabHeap', tabKey: 'pages.index.xrayTabHeap',
title: 'pages.index.xrayTitleHeap', title: 'pages.index.xrayTitleHeap',
icon: <DatabaseOutlined />, icon: <DatabaseOutlined aria-hidden />,
unit: 'B', unit: 'B',
stroke: '#7c4dff', stroke: '#7c4dff',
}, },
@@ -62,7 +62,7 @@ const METRICS: MetricDef[] = [
tab: 'Sys', tab: 'Sys',
tabKey: 'pages.index.xrayTabSys', tabKey: 'pages.index.xrayTabSys',
title: 'pages.index.xrayTitleSys', title: 'pages.index.xrayTitleSys',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined aria-hidden />,
unit: 'B', unit: 'B',
stroke: '#1890ff', stroke: '#1890ff',
}, },
@@ -71,7 +71,7 @@ const METRICS: MetricDef[] = [
tab: 'Objects', tab: 'Objects',
tabKey: 'pages.index.xrayTabObjects', tabKey: 'pages.index.xrayTabObjects',
title: 'pages.index.xrayTitleObjects', title: 'pages.index.xrayTitleObjects',
icon: <BlockOutlined />, icon: <BlockOutlined aria-hidden />,
unit: '', unit: '',
stroke: '#13c2c2', stroke: '#13c2c2',
}, },
@@ -80,7 +80,7 @@ const METRICS: MetricDef[] = [
tab: 'GC Count', tab: 'GC Count',
tabKey: 'pages.index.xrayTabGcCount', tabKey: 'pages.index.xrayTabGcCount',
title: 'pages.index.xrayTitleGcCount', title: 'pages.index.xrayTitleGcCount',
icon: <DeleteOutlined />, icon: <DeleteOutlined aria-hidden />,
unit: '', unit: '',
stroke: '#fa8c16', stroke: '#fa8c16',
}, },
@@ -89,7 +89,7 @@ const METRICS: MetricDef[] = [
tab: 'GC Pause', tab: 'GC Pause',
tabKey: 'pages.index.xrayTabGcPause', tabKey: 'pages.index.xrayTabGcPause',
title: 'pages.index.xrayTitleGcPause', title: 'pages.index.xrayTitleGcPause',
icon: <PauseCircleOutlined />, icon: <PauseCircleOutlined aria-hidden />,
unit: 'ns', unit: 'ns',
stroke: '#f5222d', stroke: '#f5222d',
}, },
@@ -98,7 +98,7 @@ const METRICS: MetricDef[] = [
tab: 'Observatory', tab: 'Observatory',
tabKey: 'pages.index.xrayTabObservatory', tabKey: 'pages.index.xrayTabObservatory',
title: 'pages.index.xrayTitleObservatory', title: 'pages.index.xrayTitleObservatory',
icon: <EyeOutlined />, icon: <EyeOutlined aria-hidden />,
unit: 'ms', unit: 'ms',
stroke: '#52c41a', stroke: '#52c41a',
}, },
@@ -304,6 +304,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
<div className="metric-modal-title"> <div className="metric-modal-title">
<span>{t('pages.index.xrayMetricsTitle')}</span> <span>{t('pages.index.xrayMetricsTitle')}</span>
<Select <Select
aria-label={t('pages.index.timeRange')}
value={bucket} value={bucket}
size="small" size="small"
className="bucket-select" className="bucket-select"
@@ -362,6 +363,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
) : ( ) : (
<div className="obs-controls"> <div className="obs-controls">
<Select <Select
aria-label={t('pages.index.xrayObservatoryTagPlaceholder')}
value={obsActiveTag} value={obsActiveTag}
size="small" size="small"
className="obs-select" className="obs-select"
+11 -5
View File
@@ -128,7 +128,13 @@ export default function LoginPage() {
[], [],
); );
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />; const themeIcon = !isDark ? (
<SunOutlined aria-hidden />
) : !isUltra ? (
<MoonOutlined aria-hidden />
) : (
<MoonFilled aria-hidden />
);
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
@@ -167,7 +173,7 @@ export default function LoginPage() {
size="large" size="large"
className="toolbar-btn" className="toolbar-btn"
aria-label={t('pages.settings.language')} aria-label={t('pages.settings.language')}
icon={<TranslationOutlined />} icon={<TranslationOutlined aria-hidden />}
/> />
</Popover> </Popover>
</div> </div>
@@ -199,7 +205,7 @@ export default function LoginPage() {
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }} rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
> >
<Input <Input
prefix={<UserOutlined />} prefix={<UserOutlined aria-hidden />}
autoComplete="username" autoComplete="username"
size="large" size="large"
placeholder={t('username')} placeholder={t('username')}
@@ -213,7 +219,7 @@ export default function LoginPage() {
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }} rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
> >
<Input.Password <Input.Password
prefix={<LockOutlined />} prefix={<LockOutlined aria-hidden />}
autoComplete="current-password" autoComplete="current-password"
size="large" size="large"
placeholder={t('password')} placeholder={t('password')}
@@ -227,7 +233,7 @@ export default function LoginPage() {
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }} rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
> >
<Input <Input
prefix={<KeyOutlined />} prefix={<KeyOutlined aria-hidden />}
autoComplete="one-time-code" autoComplete="one-time-code"
size="large" size="large"
placeholder={t('twoFactorCode')} placeholder={t('twoFactorCode')}
+25 -22
View File
@@ -252,7 +252,7 @@ export default function NodeList({
parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-', parent: record.parentGuid ? nameByGuid.get(record.parentGuid) || '-' : '-',
})} })}
> >
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}> <Tag icon={<ApartmentOutlined aria-hidden />} style={{ margin: 0 }}>
{t('pages.nodes.subNode')} {t('pages.nodes.subNode')}
</Tag> </Tag>
</Tooltip> </Tooltip>
@@ -263,7 +263,7 @@ export default function NodeList({
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined aria-hidden />}
aria-label={t('pages.nodes.probe')} aria-label={t('pages.nodes.probe')}
onClick={() => onProbe(record)} onClick={() => onProbe(record)}
/> />
@@ -274,7 +274,7 @@ export default function NodeList({
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined aria-hidden />}
aria-label={t('pages.nodes.updatePanel')} aria-label={t('pages.nodes.updatePanel')}
onClick={() => onUpdateNode(record)} onClick={() => onUpdateNode(record)}
/> />
@@ -285,7 +285,7 @@ export default function NodeList({
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
aria-label={t('edit')} aria-label={t('edit')}
onClick={() => onEdit(record)} onClick={() => onEdit(record)}
/> />
@@ -296,7 +296,7 @@ export default function NodeList({
size="small" size="small"
danger danger
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
aria-label={t('delete')} aria-label={t('delete')}
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
/> />
@@ -316,6 +316,7 @@ export default function NodeList({
<Switch <Switch
checked={!!record.enable} checked={!!record.enable}
size="small" size="small"
aria-label={t('pages.nodes.enable')}
onChange={(v) => onToggleEnable(record, v)} onChange={(v) => onToggleEnable(record, v)}
/> />
), ),
@@ -331,7 +332,7 @@ export default function NodeList({
> >
<span className="name"> <span className="name">
{record.transitive && ( {record.transitive && (
<ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} /> <ApartmentOutlined aria-hidden style={{ marginInlineEnd: 6, opacity: 0.6 }} />
)} )}
{record.name} {record.name}
</span> </span>
@@ -391,7 +392,7 @@ export default function NodeList({
<StatusLabel status={record.status} xrayState={record.xrayState} /> <StatusLabel status={record.status} xrayState={record.xrayState} />
{tip && ( {tip && (
<Tooltip title={tip}> <Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} /> <ExclamationCircleOutlined style={{ color: iconColor }} aria-label={tip} />
</Tooltip> </Tooltip>
)} )}
</Space> </Space>
@@ -460,7 +461,7 @@ export default function NodeList({
render: (_value, record) => ( render: (_value, record) => (
<Space size={2}> <Space size={2}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}> <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
<TeamOutlined /> {record.clientCount || 0} <TeamOutlined aria-hidden /> {record.clientCount || 0}
</Tag> </Tag>
{record.activeCount ? ( {record.activeCount ? (
<Tooltip title={t('subscription.active')}> <Tooltip title={t('subscription.active')}>
@@ -562,7 +563,7 @@ export default function NodeList({
locale={{ locale={{
emptyText: ( emptyText: (
<div className="card-empty"> <div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <ClusterOutlined aria-hidden style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
), ),
@@ -579,14 +580,14 @@ export default function NodeList({
return ( return (
<Card size="small" hoverable> <Card size="small" hoverable>
<div className="toolbar"> <div className="toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={onAdd}>
{t('pages.nodes.addNode')} {t('pages.nodes.addNode')}
</Button> </Button>
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}> <Button icon={<SafetyCertificateOutlined aria-hidden />} onClick={onMtls}>
{t('pages.nodes.mtls.title')} {t('pages.nodes.mtls.title')}
</Button> </Button>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}> <Button icon={<CloudDownloadOutlined aria-hidden />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })} {t('pages.nodes.updateSelected', { count: selectedIds.length })}
</Button> </Button>
)} )}
@@ -597,7 +598,7 @@ export default function NodeList({
<div className="node-cards"> <div className="node-cards">
{dataSource.length === 0 ? ( {dataSource.length === 0 ? (
<div className="card-empty"> <div className="card-empty">
<ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} /> <ClusterOutlined aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
) : ( ) : (
@@ -609,11 +610,11 @@ export default function NodeList({
style={{ paddingInlineStart: 16, opacity: 0.85 }} style={{ paddingInlineStart: 16, opacity: 0.85 }}
> >
<div className="card-head"> <div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} /> <ApartmentOutlined aria-hidden style={{ opacity: 0.6 }} />
<StatusDot status={record.status} xrayState={record.xrayState} /> <StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span> <span className="node-name">{record.name}</span>
<div className="card-actions"> <div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}> <Tag icon={<ApartmentOutlined aria-hidden />} style={{ margin: 0 }}>
{t('pages.nodes.subNode')} {t('pages.nodes.subNode')}
</Tag> </Tag>
</div> </div>
@@ -653,6 +654,7 @@ export default function NodeList({
<Switch <Switch
checked={!!record.enable} checked={!!record.enable}
size="small" size="small"
aria-label={t('pages.nodes.enable')}
onChange={(v) => onToggleEnable(record, v)} onChange={(v) => onToggleEnable(record, v)}
/> />
<Dropdown <Dropdown
@@ -664,7 +666,7 @@ export default function NodeList({
key: 'probe', key: 'probe',
label: ( label: (
<> <>
<ThunderboltOutlined /> {t('pages.nodes.probe')} <ThunderboltOutlined aria-hidden /> {t('pages.nodes.probe')}
</> </>
), ),
onClick: () => onProbe(record), onClick: () => onProbe(record),
@@ -675,7 +677,8 @@ export default function NodeList({
key: 'update', key: 'update',
label: ( label: (
<> <>
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')} <CloudDownloadOutlined aria-hidden />{' '}
{t('pages.nodes.updatePanel')}
</> </>
), ),
onClick: () => onUpdateNode(record), onClick: () => onUpdateNode(record),
@@ -686,7 +689,7 @@ export default function NodeList({
key: 'edit', key: 'edit',
label: ( label: (
<> <>
<EditOutlined /> {t('edit')} <EditOutlined aria-hidden /> {t('edit')}
</> </>
), ),
onClick: () => onEdit(record), onClick: () => onEdit(record),
@@ -696,7 +699,7 @@ export default function NodeList({
danger: true, danger: true,
label: ( label: (
<> <>
<DeleteOutlined /> {t('delete')} <DeleteOutlined aria-hidden /> {t('delete')}
</> </>
), ),
onClick: () => onDelete(record), onClick: () => onDelete(record),
@@ -708,7 +711,7 @@ export default function NodeList({
type="text" type="text"
size="small" size="small"
className="row-action-trigger" className="row-action-trigger"
icon={<MoreOutlined />} icon={<MoreOutlined aria-hidden />}
aria-label={t('more')} aria-label={t('more')}
/> />
</Dropdown> </Dropdown>
@@ -782,7 +785,7 @@ export default function NodeList({
const { tip, iconColor } = statusIssue(statsNode); const { tip, iconColor } = statusIssue(statsNode);
return tip ? ( return tip ? (
<Tooltip title={tip}> <Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} /> <ExclamationCircleOutlined style={{ color: iconColor }} aria-label={tip} />
</Tooltip> </Tooltip>
) : null; ) : null;
})()} })()}
@@ -820,7 +823,7 @@ export default function NodeList({
<div className="stat-row"> <div className="stat-row">
<span className="stat-label">{t('clients')}</span> <span className="stat-label">{t('clients')}</span>
<Tag> <Tag>
<TeamOutlined /> {statsNode.clientCount || 0} <TeamOutlined aria-hidden /> {statsNode.clientCount || 0}
</Tag> </Tag>
{statsNode.activeCount ? ( {statsNode.activeCount ? (
<Tag color="green"> <Tag color="green">
+11 -4
View File
@@ -313,7 +313,7 @@ export default function NodesPage() {
<Statistic <Statistic
title={t('pages.nodes.totalNodes')} title={t('pages.nodes.totalNodes')}
value={String(totals.total)} value={String(totals.total)}
prefix={<CloudServerOutlined />} prefix={<CloudServerOutlined aria-hidden />}
/> />
</Col> </Col>
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
@@ -321,7 +321,10 @@ export default function NodesPage() {
title={t('pages.nodes.onlineNodes')} title={t('pages.nodes.onlineNodes')}
value={String(totals.online)} value={String(totals.online)}
prefix={ prefix={
<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} /> <CheckCircleOutlined
aria-hidden
style={{ color: 'var(--ant-color-success)' }}
/>
} }
/> />
</Col> </Col>
@@ -330,7 +333,10 @@ export default function NodesPage() {
title={t('pages.nodes.offlineNodes')} title={t('pages.nodes.offlineNodes')}
value={String(totals.offline)} value={String(totals.offline)}
prefix={ prefix={
<CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} /> <CloseCircleOutlined
aria-hidden
style={{ color: 'var(--ant-color-error)' }}
/>
} }
/> />
</Col> </Col>
@@ -338,7 +344,7 @@ export default function NodesPage() {
<Statistic <Statistic
title={t('pages.nodes.avgLatency')} title={t('pages.nodes.avgLatency')}
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'} value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
prefix={<ThunderboltOutlined />} prefix={<ThunderboltOutlined aria-hidden />}
/> />
</Col> </Col>
</Row> </Row>
@@ -398,6 +404,7 @@ export default function NodesPage() {
</Typography.Paragraph> </Typography.Paragraph>
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text> <Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
<Input.TextArea <Input.TextArea
aria-label={t('pages.nodes.mtls.trustLabel')}
rows={5} rows={5}
value={trustCa} value={trustCa}
onChange={(e) => setTrustCa(e.target.value)} onChange={(e) => setTrustCa(e.target.value)}
+11 -3
View File
@@ -67,7 +67,11 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -138,7 +142,7 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}> <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined aria-hidden />}
loading={testLoading} loading={testLoading}
disabled={!allSetting.discordBotEnable} disabled={!allSetting.discordBotEnable}
onClick={handleTestDiscord} onClick={handleTestDiscord}
@@ -159,7 +163,11 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile), label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
+11 -3
View File
@@ -56,7 +56,11 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.smtpSettings'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -184,7 +188,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}> <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined aria-hidden />}
loading={testLoading} loading={testLoading}
onClick={handleTestSmtp} onClick={handleTestSmtp}
> >
@@ -213,7 +217,11 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile), label: catTabLabel(
<MailOutlined aria-hidden />,
t('pages.settings.emailNotifications'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
+26 -6
View File
@@ -180,7 +180,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -349,7 +353,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile), label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -386,7 +394,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile), label: catTabLabel(
<SafetyCertificateOutlined aria-hidden />,
t('pages.settings.certs'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -414,7 +426,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}, },
{ {
key: '4', key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile), label: catTabLabel(
<GlobalOutlined aria-hidden />,
t('pages.settings.externalTraffic'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -443,7 +459,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}, },
{ {
key: '5', key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile), label: catTabLabel(
<ClockCircleOutlined aria-hidden />,
t('pages.settings.dateAndTime'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -476,7 +496,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
}, },
{ {
key: '6', key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile), label: catTabLabel(<ApartmentOutlined aria-hidden />, 'LDAP', isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}> <SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
@@ -80,7 +80,7 @@ export default function HappSettingsContent({
{ {
key: 'routing', key: 'routing',
label: catTabLabel( label: catTabLabel(
<BranchesOutlined />, <BranchesOutlined aria-hidden />,
t('pages.settings.subHappGroupRouting'), t('pages.settings.subHappGroupRouting'),
isMobile, isMobile,
), ),
@@ -136,7 +136,7 @@ export default function HappSettingsContent({
title={t('pages.settings.subHappVisualBuilder')} title={t('pages.settings.subHappVisualBuilder')}
description={t('pages.settings.subHappVisualBuilderDesc')} description={t('pages.settings.subHappVisualBuilderDesc')}
> >
<Button icon={<BuildOutlined />} onClick={() => setIsModalOpen(true)}> <Button icon={<BuildOutlined aria-hidden />} onClick={() => setIsModalOpen(true)}>
{t('pages.settings.subHappVisualBuilder')} {t('pages.settings.subHappVisualBuilder')}
</Button> </Button>
</SettingListItem> </SettingListItem>
@@ -182,7 +182,7 @@ export default function HappSettingsContent({
{ {
key: 'appearance', key: 'appearance',
label: catTabLabel( label: catTabLabel(
<DesktopOutlined />, <DesktopOutlined aria-hidden />,
t('pages.settings.subHappGroupThemes'), t('pages.settings.subHappGroupThemes'),
isMobile, isMobile,
), ),
@@ -284,6 +284,7 @@ export default function HappSettingsContent({
> >
<Space orientation="vertical" style={{ width: '100%' }}> <Space orientation="vertical" style={{ width: '100%' }}>
<Input <Input
aria-label={t('pages.settings.subHappColorProfile')}
value={allSetting.subHappColorProfile} value={allSetting.subHappColorProfile}
placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors' placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })} onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
@@ -326,7 +327,7 @@ export default function HappSettingsContent({
{ {
key: 'network', key: 'network',
label: catTabLabel( label: catTabLabel(
<ThunderboltOutlined />, <ThunderboltOutlined aria-hidden />,
t('pages.settings.subHappGroupNetwork'), t('pages.settings.subHappGroupNetwork'),
isMobile, isMobile,
), ),
@@ -493,7 +494,7 @@ export default function HappSettingsContent({
{ {
key: 'failover', key: 'failover',
label: catTabLabel( label: catTabLabel(
<CloudSyncOutlined />, <CloudSyncOutlined aria-hidden />,
t('pages.settings.subHappGroupFailover'), t('pages.settings.subHappGroupFailover'),
isMobile, isMobile,
), ),
@@ -53,7 +53,11 @@ export default function IncySettingsContent({
items={[ items={[
{ {
key: 'app', key: 'app',
label: catTabLabel(<AppstoreOutlined />, t('pages.settings.subIncyGroupApp'), isMobile), label: catTabLabel(
<AppstoreOutlined aria-hidden />,
t('pages.settings.subIncyGroupApp'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -127,7 +131,7 @@ export default function IncySettingsContent({
{ {
key: 'banners', key: 'banners',
label: catTabLabel( label: catTabLabel(
<NotificationOutlined />, <NotificationOutlined aria-hidden />,
t('pages.settings.subIncyGroupBanners'), t('pages.settings.subIncyGroupBanners'),
isMobile, isMobile,
), ),
@@ -196,7 +200,7 @@ export default function IncySettingsContent({
{ {
key: 'privacy', key: 'privacy',
label: catTabLabel( label: catTabLabel(
<SafetyOutlined />, <SafetyOutlined aria-hidden />,
t('pages.settings.subIncyGroupPrivacy'), t('pages.settings.subIncyGroupPrivacy'),
isMobile, isMobile,
), ),
@@ -291,7 +295,7 @@ export default function IncySettingsContent({
{ {
key: 'network', key: 'network',
label: catTabLabel( label: catTabLabel(
<ThunderboltOutlined />, <ThunderboltOutlined aria-hidden />,
t('pages.settings.subIncyGroupNetwork'), t('pages.settings.subIncyGroupNetwork'),
isMobile, isMobile,
), ),
@@ -450,7 +454,7 @@ export default function IncySettingsContent({
{ {
key: 'routing', key: 'routing',
label: catTabLabel( label: catTabLabel(
<BranchesOutlined />, <BranchesOutlined aria-hidden />,
t('pages.settings.subIncyGroupRouting'), t('pages.settings.subIncyGroupRouting'),
isMobile, isMobile,
), ),
@@ -8,6 +8,7 @@ interface SecretInputProps {
placeholder: string; placeholder: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onClearArmedChange: (armed: boolean) => void; onClearArmedChange: (armed: boolean) => void;
'aria-labelledby'?: string;
} }
export default function SecretInput({ export default function SecretInput({
@@ -17,11 +18,13 @@ export default function SecretInput({
placeholder, placeholder,
onChange, onChange,
onClearArmedChange, onClearArmedChange,
'aria-labelledby': ariaLabelledBy,
}: SecretInputProps) { }: SecretInputProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
<Input.Password <Input.Password
aria-labelledby={ariaLabelledBy}
value={value} value={value}
placeholder={configured && !clearArmed ? placeholder : ''} placeholder={configured && !clearArmed ? placeholder : ''}
onChange={(e) => { onChange={(e) => {
+19 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
@@ -74,6 +74,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) { export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
@@ -308,7 +309,11 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile), label: catTabLabel(
<UserOutlined aria-hidden />,
t('pages.settings.security.admin'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}> <SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
@@ -351,7 +356,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
{ {
key: '2', key: '2',
label: catTabLabel( label: catTabLabel(
<SafetyOutlined />, <SafetyOutlined aria-hidden />,
t('pages.settings.security.twoFactor'), t('pages.settings.security.twoFactor'),
isMobile, isMobile,
), ),
@@ -367,7 +372,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile), label: catTabLabel(<ApiOutlined aria-hidden />, t('pages.nodes.apiToken'), isMobile),
children: ( children: (
<div className="api-token-section"> <div className="api-token-section">
<div className="api-token-header"> <div className="api-token-header">
@@ -387,7 +392,9 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<div className="api-token-row-head"> <div className="api-token-row-head">
<div className="api-token-name-wrap"> <div className="api-token-name-wrap">
<div className="api-token-name-line"> <div className="api-token-name-line">
<span className="api-token-name">{row.name}</span> <span id={`${uid}-token-${row.id}`} className="api-token-name">
{row.name}
</span>
<Tag color={scopeMeta[row.scope].color}> <Tag color={scopeMeta[row.scope].color}>
{scopeMeta[row.scope].label} {scopeMeta[row.scope].label}
</Tag> </Tag>
@@ -399,6 +406,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<div className="api-token-actions"> <div className="api-token-actions">
<Switch <Switch
size="small" size="small"
aria-labelledby={`${uid}-token-${row.id}`}
checked={row.enabled} checked={row.enabled}
onChange={() => toggleTokenEnabled(row)} onChange={() => toggleTokenEnabled(row)}
/> />
@@ -431,8 +439,13 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
> >
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label={t('pages.settings.security.apiTokenName') || 'Name'} required> <Form.Item
label={t('pages.settings.security.apiTokenName') || 'Name'}
htmlFor={`${uid}-tokenName`}
required
>
<Input <Input
id={`${uid}-tokenName`}
value={createName} value={createName}
maxLength={64} maxLength={64}
placeholder={ placeholder={
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd'; import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
@@ -59,6 +59,7 @@ export default function SubBalancerFormModal({
onConfirm, onConfirm,
}: SubBalancerFormModalProps) { }: SubBalancerFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) }); const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
const isEdit = balancer != null; const isEdit = balancer != null;
@@ -199,6 +200,7 @@ export default function SubBalancerFormModal({
return ( return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span <span
id={`${uid}-weight-${id}`}
title={option?.title} title={option?.title}
style={{ style={{
minWidth: 0, minWidth: 0,
@@ -214,6 +216,8 @@ export default function SubBalancerFormModal({
name={`memberWeights.${id}`} name={`memberWeights.${id}`}
render={({ field }) => ( render={({ field }) => (
<InputNumber <InputNumber
aria-labelledby={`${uid}-weight-${id}`}
aria-describedby={`${uid}-weights-hint`}
min={0.1} min={0.1}
step={0.1} step={0.1}
precision={1} precision={1}
@@ -227,6 +231,9 @@ export default function SubBalancerFormModal({
); );
})} })}
</div> </div>
<span id={`${uid}-weights-hint`} hidden>
{t('pages.settings.subBalancers.weightsHelp')}
</span>
</Form.Item> </Form.Item>
)} )}
@@ -156,7 +156,12 @@ export default function SubscriptionBalancersTab({
width: 80, width: 80,
align: 'center' as const, align: 'center' as const,
render: (_: unknown, r: SubBalancer) => ( render: (_: unknown, r: SubBalancer) => (
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} /> <Switch
size="small"
aria-label={t('pages.settings.subBalancers.enabled')}
checked={r.enabled}
onChange={() => toggleEnabled(r)}
/>
), ),
}, },
{ {
@@ -168,7 +173,7 @@ export default function SubscriptionBalancersTab({
<Button <Button
aria-label={t('edit')} aria-label={t('edit')}
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
title={t('edit')} title={t('edit')}
onClick={() => { onClick={() => {
setEditing(r); setEditing(r);
@@ -181,7 +186,12 @@ export default function SubscriptionBalancersTab({
cancelText={t('cancel')} cancelText={t('cancel')}
onConfirm={() => remove(r.id)} onConfirm={() => remove(r.id)}
> >
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} /> <Button
aria-label={t('delete')}
size="small"
danger
icon={<DeleteOutlined aria-hidden />}
/>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -212,7 +222,7 @@ export default function SubscriptionBalancersTab({
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => { onClick={() => {
setEditing(null); setEditing(null);
setModalOpen(true); setModalOpen(true);
@@ -332,7 +342,7 @@ export default function SubscriptionBalancersTab({
{ {
key: 'balancers', key: 'balancers',
label: catTabLabel( label: catTabLabel(
<DeploymentUnitOutlined />, <DeploymentUnitOutlined aria-hidden />,
t('pages.settings.subBalancers.tabBalancers'), t('pages.settings.subBalancers.tabBalancers'),
isMobile, isMobile,
), ),
@@ -341,7 +351,7 @@ export default function SubscriptionBalancersTab({
{ {
key: 'observatory', key: 'observatory',
label: catTabLabel( label: catTabLabel(
<RadarChartOutlined />, <RadarChartOutlined aria-hidden />,
t('pages.settings.subBalancers.tabObservatory'), t('pages.settings.subBalancers.tabObservatory'),
isMobile, isMobile,
), ),
@@ -158,7 +158,11 @@ export default function SubscriptionFormatsTab({
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: ( children: (
<div className="subscription-format-sections"> <div className="subscription-format-sections">
{allSetting.subJsonEnable && ( {allSetting.subJsonEnable && (
@@ -167,7 +171,7 @@ export default function SubscriptionFormatsTab({
className="subscription-format-card" className="subscription-format-card"
title={ title={
<span className="subscription-format-card-title"> <span className="subscription-format-card-title">
<FileTextOutlined /> <FileTextOutlined aria-hidden />
{t('pages.settings.subJsonEnableTitle')} {t('pages.settings.subJsonEnableTitle')}
</span> </span>
} }
@@ -261,7 +265,7 @@ export default function SubscriptionFormatsTab({
className="subscription-format-card" className="subscription-format-card"
title={ title={
<span className="subscription-format-card-title"> <span className="subscription-format-card-title">
<NodeIndexOutlined /> <NodeIndexOutlined aria-hidden />
{t('pages.settings.subClashEnableTitle')} {t('pages.settings.subClashEnableTitle')}
</span> </span>
} }
@@ -322,7 +326,7 @@ export default function SubscriptionFormatsTab({
{ {
key: '2', key: '2',
label: catTabLabel( label: catTabLabel(
<RocketOutlined />, <RocketOutlined aria-hidden />,
t('pages.settings.subFormats.finalMask'), t('pages.settings.subFormats.finalMask'),
isMobile, isMobile,
), ),
@@ -342,7 +346,7 @@ export default function SubscriptionFormatsTab({
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile), label: catTabLabel(<PartitionOutlined aria-hidden />, t('pages.settings.mux'), isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -396,7 +400,7 @@ export default function SubscriptionFormatsTab({
}, },
{ {
key: '4', key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile), label: catTabLabel(<SendOutlined aria-hidden />, t('pages.settings.direct'), isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -445,7 +449,7 @@ export default function SubscriptionFormatsTab({
}, },
{ {
key: '5', key: '5',
label: catTabLabel(<StopOutlined />, t('pages.settings.block'), isMobile), label: catTabLabel(<StopOutlined aria-hidden />, t('pages.settings.block'), isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -48,7 +48,11 @@ export default function SubscriptionGeneralTab({
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -153,7 +157,11 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile), label: catTabLabel(
<InfoCircleOutlined aria-hidden />,
t('pages.settings.information'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -273,7 +281,7 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile), label: catTabLabel(<IdcardOutlined aria-hidden />, t('pages.settings.profile'), isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -379,7 +387,11 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '4', key: '4',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile), label: catTabLabel(
<SafetyCertificateOutlined aria-hidden />,
t('pages.settings.certs'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -407,7 +419,7 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '5', key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile), label: catTabLabel(<BranchesOutlined aria-hidden />, 'Happ', isMobile),
children: ( children: (
<HappSettingsContent <HappSettingsContent
allSetting={allSetting} allSetting={allSetting}
@@ -419,7 +431,7 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '6', key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile), label: catTabLabel(<NodeIndexOutlined aria-hidden />, 'Clash / Mihomo', isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -452,7 +464,7 @@ export default function SubscriptionGeneralTab({
}, },
{ {
key: '7', key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile), label: catTabLabel(<CompassOutlined aria-hidden />, 'Incy', isMobile),
children: ( children: (
<IncySettingsContent <IncySettingsContent
allSetting={allSetting} allSetting={allSetting}
+11 -3
View File
@@ -65,7 +65,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
items={[ items={[
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -133,7 +137,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}> <Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined aria-hidden />}
loading={testLoading} loading={testLoading}
onClick={handleTestTgBot} onClick={handleTestTgBot}
> >
@@ -153,7 +157,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile), label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
+5 -1
View File
@@ -6,9 +6,13 @@ import { Tooltip } from 'antd';
old top tab bar's icons-only behaviour. */ old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode { export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) { if (iconsOnly) {
// Callers hide the icon for the desktop label; here it is the tab's only name.
const labelledIcon = const labelledIcon =
typeof text === 'string' && isValidElement(icon) typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text }) ? cloneElement(icon as ReactElement<{ 'aria-label'?: string; 'aria-hidden'?: boolean }>, {
'aria-label': text,
'aria-hidden': undefined,
})
: icon; : icon;
return <Tooltip title={text}>{labelledIcon}</Tooltip>; return <Tooltip title={text}>{labelledIcon}</Tooltip>;
} }
+4 -3
View File
@@ -16,7 +16,7 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
return ( return (
<Button <Button
type="primary" type="primary"
icon={<CrownOutlined />} icon={<CrownOutlined aria-hidden />}
href={contact} href={contact}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -32,7 +32,7 @@ function YourBrandCard({ contact }: { contact?: string }) {
return ( return (
<div className="sponsors-yourbrand"> <div className="sponsors-yourbrand">
<span className="sponsors-yourbrand-icon"> <span className="sponsors-yourbrand-icon">
<PlusOutlined /> <PlusOutlined aria-hidden />
</span> </span>
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div> <div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div> <div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
@@ -64,7 +64,8 @@ export default function SponsorsPage() {
<div className="sponsors-header"> <div className="sponsors-header">
<div> <div>
<Typography.Title level={3} className="sponsors-title"> <Typography.Title level={3} className="sponsors-title">
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')} <CrownOutlined aria-hidden className="sponsors-title-icon" />{' '}
{t('pages.sponsors.title')}
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text> <Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
</div> </div>
+2 -2
View File
@@ -13,8 +13,8 @@ interface SubAppsTabProps {
} }
const PLATFORM_OPTIONS = [ const PLATFORM_OPTIONS = [
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> }, { value: 'android' as const, label: 'Android', icon: <AndroidOutlined aria-hidden /> },
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> }, { value: 'ios' as const, label: 'iOS', icon: <AppleOutlined aria-hidden /> },
]; ];
function AppIcon({ name }: { name: string }) { function AppIcon({ name }: { name: string }) {
+2 -2
View File
@@ -24,7 +24,7 @@ export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
<div className="sub-rows"> <div className="sub-rows">
<div className="sub-configs-bar"> <div className="sub-configs-bar">
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))} onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
> >
{t('subscription.copyAllConfigs')} {t('subscription.copyAllConfigs')}
@@ -44,7 +44,7 @@ export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
</span> </span>
<div className="sub-row-actions"> <div className="sub-row-actions">
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
onClick={() => onCopy(link)} onClick={() => onCopy(link)}
aria-label={t('copy')} aria-label={t('copy')}
title={t('copy')} title={t('copy')}
+9 -3
View File
@@ -53,14 +53,20 @@ export default function SubHeader({ title, sId, email, lang, onLangChange }: Sub
[], [],
); );
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />; const themeIcon = !isDark ? (
<SunOutlined aria-hidden />
) : !isUltra ? (
<MoonOutlined aria-hidden />
) : (
<MoonFilled aria-hidden />
);
const initial = Array.from(title)[0]?.toUpperCase(); const initial = Array.from(title)[0]?.toUpperCase();
return ( return (
<header className="sub-header"> <header className="sub-header">
<div className="sub-brand"> <div className="sub-brand">
<span className="sub-brand-mark" aria-hidden="true"> <span className="sub-brand-mark" aria-hidden="true">
{initial ?? <WifiOutlined />} {initial ?? <WifiOutlined aria-hidden />}
</span> </span>
<div className="sub-brand-text"> <div className="sub-brand-text">
<div className="sub-brand-title" dir="auto"> <div className="sub-brand-title" dir="auto">
@@ -103,7 +109,7 @@ export default function SubHeader({ title, sId, email, lang, onLangChange }: Sub
size="large" size="large"
className="toolbar-btn" className="toolbar-btn"
aria-label={t('pages.settings.language')} aria-label={t('pages.settings.language')}
icon={<TranslationOutlined />} icon={<TranslationOutlined aria-hidden />}
/> />
</Popover> </Popover>
</div> </div>
+2 -2
View File
@@ -49,13 +49,13 @@ export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }:
href={appendRawView(row.url)} href={appendRawView(row.url)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
icon={<DownloadOutlined />} icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')} aria-label={t('download')}
title={t('download')} title={t('download')}
/> />
)} )}
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined aria-hidden />}
onClick={() => onCopy(row.url)} onClick={() => onCopy(row.url)}
aria-label={t('copy')} aria-label={t('copy')}
title={t('copy')} title={t('copy')}
+5 -5
View File
@@ -92,7 +92,7 @@ export default function SubPage() {
if (subUrl || subJsonUrl || subClashUrl) { if (subUrl || subJsonUrl || subClashUrl) {
items.push({ items.push({
key: 'subscription', key: 'subscription',
icon: <LinkOutlined />, icon: <LinkOutlined aria-hidden />,
label: t('subscription.tabLinks'), label: t('subscription.tabLinks'),
children: ( children: (
<SubLinksTab <SubLinksTab
@@ -107,7 +107,7 @@ export default function SubPage() {
if (subUrl) { if (subUrl) {
items.push({ items.push({
key: 'apps', key: 'apps',
icon: <AppstoreOutlined />, icon: <AppstoreOutlined aria-hidden />,
label: t('subscription.tabApps'), label: t('subscription.tabApps'),
children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />, children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
}); });
@@ -115,7 +115,7 @@ export default function SubPage() {
if (links.length > 0) { if (links.length > 0) {
items.push({ items.push({
key: 'configs', key: 'configs',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined aria-hidden />,
label: ( label: (
<> <>
{t('subscription.tabConfigs')} {t('subscription.tabConfigs')}
@@ -153,13 +153,13 @@ export default function SubPage() {
<footer className="sub-footer"> <footer className="sub-footer">
{updateHours > 0 && ( {updateHours > 0 && (
<span> <span>
<ClockCircleOutlined /> <ClockCircleOutlined aria-hidden />
{t('subscription.updateInterval', { hours: updateHours })} {t('subscription.updateInterval', { hours: updateHours })}
</span> </span>
)} )}
{subSupportUrl && ( {subSupportUrl && (
<a href={subSupportUrl} target="_blank" rel="noopener noreferrer"> <a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
<CustomerServiceOutlined /> <CustomerServiceOutlined aria-hidden />
{t('subscription.support')} {t('subscription.support')}
</a> </a>
)} )}
+14 -3
View File
@@ -25,7 +25,12 @@ export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps)
return ( return (
<> <>
<Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} /> <Button
icon={<QrcodeOutlined aria-hidden />}
aria-label="QR"
title="QR"
onClick={() => setOpen(true)}
/>
<Modal <Modal
open={open} open={open}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
@@ -49,6 +54,7 @@ export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps)
/> />
</div> </div>
<Input.TextArea <Input.TextArea
aria-label={t('subscription.tabLinks')}
className="sub-qr-modal-link" className="sub-qr-modal-link"
value={value} value={value}
readOnly readOnly
@@ -56,10 +62,15 @@ export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps)
autoSize={{ minRows: 2, maxRows: 5 }} autoSize={{ minRows: 2, maxRows: 5 }}
/> />
<div className="sub-qr-modal-actions"> <div className="sub-qr-modal-actions">
<Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}> <Button
type="primary"
size="large"
icon={<CopyOutlined aria-hidden />}
onClick={() => onCopy(value)}
>
{t('copy')} {t('copy')}
</Button> </Button>
<Button size="large" icon={<DownloadOutlined />} onClick={saveQr}> <Button size="large" icon={<DownloadOutlined aria-hidden />} onClick={saveQr}>
{t('subscription.saveQr')} {t('subscription.saveQr')}
</Button> </Button>
</div> </div>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useId, useMemo, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd'; import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
@@ -68,6 +68,7 @@ export default function BalancerFormModal({
onConfirm, onConfirm,
}: BalancerFormModalProps) { }: BalancerFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const uid = useId();
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) }); const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null; const isEdit = balancer != null;
@@ -226,6 +227,7 @@ export default function BalancerFormModal({
return ( return (
<Form.Item <Form.Item
label={t('pages.xray.balancer.tag')} label={t('pages.xray.balancer.tag')}
htmlFor={`${uid}-tag`}
required required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''} validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={ help={
@@ -234,6 +236,7 @@ export default function BalancerFormModal({
hasFeedback hasFeedback
> >
<Input <Input
id={`${uid}-tag`}
value={field.value} value={field.value}
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur} onBlur={field.onBlur}
@@ -319,7 +322,7 @@ export default function BalancerFormModal({
<Button <Button
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])} onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
/> />
@@ -345,7 +348,7 @@ export default function BalancerFormModal({
) )
} }
> >
<MinusOutlined /> <MinusOutlined aria-hidden />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
@@ -354,7 +357,7 @@ export default function BalancerFormModal({
<Button <Button
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => onClick={() =>
methods.setValue('settings.costs', [ methods.setValue('settings.costs', [
@@ -411,7 +414,7 @@ export default function BalancerFormModal({
) )
} }
> >
<MinusOutlined /> <MinusOutlined aria-hidden />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
@@ -324,7 +324,7 @@ export default function BalancersTab({
aria-label={t('edit')} aria-label={t('edit')}
shape="circle" shape="circle"
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined aria-hidden />}
onClick={() => openEdit(index)} onClick={() => openEdit(index)}
/> />
)} )}
@@ -338,7 +338,7 @@ export default function BalancersTab({
key: 'edit', key: 'edit',
label: ( label: (
<> <>
<EditOutlined /> {t('edit')} <EditOutlined aria-hidden /> {t('edit')}
</> </>
), ),
onClick: () => openEdit(index), onClick: () => openEdit(index),
@@ -350,7 +350,7 @@ export default function BalancersTab({
danger: true, danger: true,
label: ( label: (
<> <>
<DeleteOutlined /> {t('delete')} <DeleteOutlined aria-hidden /> {t('delete')}
</> </>
), ),
onClick: () => confirmDelete(index), onClick: () => confirmDelete(index),
@@ -358,7 +358,12 @@ export default function BalancersTab({
], ],
}} }}
> >
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} /> <Button
aria-label={t('more')}
shape="circle"
size="small"
icon={<MoreOutlined aria-hidden />}
/>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
@@ -458,6 +463,7 @@ export default function BalancersTab({
return ( return (
<Select <Select
size="small" size="small"
aria-label={t('pages.xray.balancerOverride')}
style={{ width: 170 }} style={{ width: 170 }}
placeholder={t('pages.xray.balancerOverridePh')} placeholder={t('pages.xray.balancerOverridePh')}
allowClear allowClear
@@ -475,20 +481,20 @@ export default function BalancersTab({
<Space orientation="vertical" size="middle" style={{ width: '100%' }}> <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<Empty description={t('emptyBalancersDesc')}> <Empty description={t('emptyBalancersDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAdd}>
{t('pages.xray.Balancers')} {t('pages.xray.Balancers')}
</Button> </Button>
</Empty> </Empty>
) : ( ) : (
<> <>
<Space> <Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAdd}>
{t('pages.xray.Balancers')} {t('pages.xray.Balancers')}
</Button> </Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}> <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
<Button <Button
aria-label={t('pages.xray.balancerLiveRefresh')} aria-label={t('pages.xray.balancerLiveRefresh')}
icon={<SyncOutlined spin={liveLoading} />} icon={<SyncOutlined aria-hidden spin={liveLoading} />}
onClick={refreshLive} onClick={refreshLive}
/> />
</Tooltip> </Tooltip>
@@ -516,7 +522,7 @@ export default function BalancersTab({
{ {
key: 'balancers', key: 'balancers',
label: catTabLabel( label: catTabLabel(
<DeploymentUnitOutlined />, <DeploymentUnitOutlined aria-hidden />,
t('pages.xray.tabBalancerSettings'), t('pages.xray.tabBalancerSettings'),
isMobile, isMobile,
), ),
@@ -524,7 +530,11 @@ export default function BalancersTab({
}, },
{ {
key: 'observatory', key: 'observatory',
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile), label: catTabLabel(
<RadarChartOutlined aria-hidden />,
t('pages.xray.tabObservatory'),
isMobile,
),
children: ( children: (
<ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} /> <ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} />
), ),
+19 -6
View File
@@ -168,7 +168,7 @@ export default function BasicsTab({
const items = [ const items = [
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile), label: catTabLabel(<SettingOutlined aria-hidden />, t('pages.xray.generalConfigs'), isMobile),
children: ( children: (
<> <>
<Alert <Alert
@@ -296,7 +296,7 @@ export default function BasicsTab({
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile), label: catTabLabel(<BarChartOutlined aria-hidden />, t('pages.xray.statistics'), isMobile),
children: ( children: (
<> <>
{[ {[
@@ -351,7 +351,11 @@ export default function BasicsTab({
}, },
{ {
key: 'connection', key: 'connection',
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile), label: catTabLabel(
<ClockCircleOutlined aria-hidden />,
t('pages.xray.connectionLimits'),
isMobile,
),
children: ( children: (
<> <>
<Alert <Alert
@@ -395,7 +399,7 @@ export default function BasicsTab({
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile), label: catTabLabel(<FileTextOutlined aria-hidden />, t('pages.xray.logConfigs'), isMobile),
children: ( children: (
<> <>
<Alert <Alert
@@ -498,10 +502,19 @@ export default function BasicsTab({
}, },
{ {
key: 'reset', key: 'reset',
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile), label: catTabLabel(
<ReloadOutlined aria-hidden />,
t('pages.settings.resetDefaultConfig'),
isMobile,
),
children: ( children: (
<Space style={{ padding: '0 20px' }}> <Space style={{ padding: '0 20px' }}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}> <Button
type="primary"
danger
icon={<ReloadOutlined aria-hidden />}
onClick={confirmResetDefault}
>
{t('pages.settings.resetDefaultConfig')} {t('pages.settings.resetDefaultConfig')}
</Button> </Button>
</Space> </Space>
@@ -221,14 +221,14 @@ export default function DnsServerModal({
<Button <Button
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => methods.setValue('domains', [...domains, ''])} onClick={() => methods.setValue('domains', [...domains, ''])}
/> />
{domains.map((_, i) => ( {domains.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}> <Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`domains.${i}`} noStyle> <FormField name={`domains.${i}`} noStyle>
<Input /> <Input aria-label={t('pages.xray.dns.domains')} />
</FormField> </FormField>
<InputAddon <InputAddon
ariaLabel={t('remove')} ariaLabel={t('remove')}
@@ -239,7 +239,7 @@ export default function DnsServerModal({
) )
} }
> >
<MinusOutlined /> <MinusOutlined aria-hidden />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
@@ -249,14 +249,14 @@ export default function DnsServerModal({
<Button <Button
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
/> />
{expectedIPs.map((_, i) => ( {expectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}> <Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`expectedIPs.${i}`} noStyle> <FormField name={`expectedIPs.${i}`} noStyle>
<Input /> <Input aria-label={t('pages.xray.dns.expectIPs')} />
</FormField> </FormField>
<InputAddon <InputAddon
ariaLabel={t('remove')} ariaLabel={t('remove')}
@@ -267,7 +267,7 @@ export default function DnsServerModal({
) )
} }
> >
<MinusOutlined /> <MinusOutlined aria-hidden />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
@@ -277,14 +277,14 @@ export default function DnsServerModal({
<Button <Button
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
aria-label={t('add')} aria-label={t('add')}
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
/> />
{unexpectedIPs.map((_, i) => ( {unexpectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}> <Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`unexpectedIPs.${i}`} noStyle> <FormField name={`unexpectedIPs.${i}`} noStyle>
<Input /> <Input aria-label={t('pages.xray.dns.unexpectIPs')} />
</FormField> </FormField>
<InputAddon <InputAddon
ariaLabel={t('remove')} ariaLabel={t('remove')}
@@ -295,7 +295,7 @@ export default function DnsServerModal({
) )
} }
> >
<MinusOutlined /> <MinusOutlined aria-hidden />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
+24 -14
View File
@@ -245,7 +245,11 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const out = [ const out = [
{ {
key: '1', key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile), label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.xray.generalConfigs'),
isMobile,
),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@@ -376,13 +380,13 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
if (dnsEnabled) { if (dnsEnabled) {
out.push({ out.push({
key: 'hosts', key: 'hosts',
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile), label: catTabLabel(<ProfileOutlined aria-hidden />, t('pages.xray.dns.hosts'), isMobile),
children: children:
hostsList.length === 0 ? ( hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}> <Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
> >
{t('pages.xray.dns.hostsAdd')} {t('pages.xray.dns.hostsAdd')}
@@ -392,7 +396,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<Space orientation="vertical" size="middle" style={{ width: '100%' }}> <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined aria-hidden />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
> >
{t('pages.xray.dns.hostsAdd')} {t('pages.xray.dns.hostsAdd')}
@@ -426,7 +430,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<Button <Button
danger danger
aria-label={t('delete')} aria-label={t('delete')}
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
/> />
</div> </div>
@@ -437,15 +441,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({ out.push({
key: '2', key: '2',
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile), label: catTabLabel(<DatabaseOutlined aria-hidden />, 'DNS', isMobile),
children: children:
dnsServers.length === 0 ? ( dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}> <Empty description={t('emptyDnsDesc')}>
<Space> <Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAddServer}>
{t('pages.xray.dns.add')} {t('pages.xray.dns.add')}
</Button> </Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}> <Button
icon={<MenuOutlined aria-hidden />}
onClick={() => setPresetsModalOpen(true)}
>
{t('pages.xray.dns.usePreset')} {t('pages.xray.dns.usePreset')}
</Button> </Button>
</Space> </Space>
@@ -453,13 +460,16 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
) : ( ) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}> <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap> <Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={openAddServer}>
{t('pages.xray.dns.add')} {t('pages.xray.dns.add')}
</Button> </Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}> <Button
icon={<MenuOutlined aria-hidden />}
onClick={() => setPresetsModalOpen(true)}
>
{t('pages.xray.dns.usePreset')} {t('pages.xray.dns.usePreset')}
</Button> </Button>
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}> <Button danger icon={<DeleteOutlined aria-hidden />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')} {t('pages.xray.dns.clearAll')}
</Button> </Button>
</Space> </Space>
@@ -477,17 +487,17 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({ out.push({
key: '3', key: '3',
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile), label: catTabLabel(<ExperimentOutlined aria-hidden />, 'Fake DNS', isMobile),
children: children:
fakeDnsList.length === 0 ? ( fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}> <Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')} {t('pages.xray.fakedns.add')}
</Button> </Button>
</Empty> </Empty>
) : ( ) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}> <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')} {t('pages.xray.fakedns.add')}
</Button> </Button>
<Table <Table
@@ -38,7 +38,7 @@ export function useDnsServerColumns({
key: 'edit', key: 'edit',
label: ( label: (
<> <>
<EditOutlined /> {t('edit')} <EditOutlined aria-hidden /> {t('edit')}
</> </>
), ),
onClick: () => openEditServer(index), onClick: () => openEditServer(index),
@@ -48,7 +48,7 @@ export function useDnsServerColumns({
danger: true, danger: true,
label: ( label: (
<> <>
<DeleteOutlined /> {t('delete')} <DeleteOutlined aria-hidden /> {t('delete')}
</> </>
), ),
onClick: () => deleteServer(index), onClick: () => deleteServer(index),
@@ -56,7 +56,12 @@ export function useDnsServerColumns({
], ],
}} }}
> >
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} /> <Button
aria-label={t('more')}
shape="circle"
size="small"
icon={<MoreOutlined aria-hidden />}
/>
</Dropdown> </Dropdown>
</Space> </Space>
), ),
@@ -107,7 +112,7 @@ export function useFakednsColumns({
shape="circle" shape="circle"
size="small" size="small"
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden />}
onClick={() => deleteFakedns(index)} onClick={() => deleteFakedns(index)}
/> />
</Space> </Space>

Some files were not shown because too many files have changed in this diff Show More