mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-30 09:45:00 +03:00
e2184777a0
* 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.
144 lines
4.4 KiB
TypeScript
144 lines
4.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import type { Ref } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Input, Space, Tooltip, Typography } from 'antd';
|
|
import type { InputRef } from 'antd';
|
|
import { DatabaseOutlined } from '@ant-design/icons';
|
|
|
|
import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
|
|
import { parseTokens } from '@/lib/xray/geoTokens';
|
|
import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
|
|
|
|
import GeoBrowserModal from './GeoBrowserModal';
|
|
|
|
const VALIDATION_DELAY = 600;
|
|
|
|
// Each reason needs its own wording: a missing database is fixed under Geodata,
|
|
// a missing category by picking another one, and a bad token by editing it.
|
|
const REASON_KEYS: Record<string, string> = {
|
|
fileMissing: 'pages.xray.geoBrowser.missingDatabase',
|
|
categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
|
|
attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
|
|
syntax: 'pages.xray.geoBrowser.invalidToken',
|
|
wrongKind: 'pages.xray.geoBrowser.wrongKind',
|
|
};
|
|
|
|
export interface GeoTokenInputProps {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
onBlur?: () => void;
|
|
kind: GeoTokenKind;
|
|
placeholder?: string;
|
|
id?: string;
|
|
'aria-describedby'?: string;
|
|
ref?: Ref<InputRef>;
|
|
}
|
|
|
|
export default function GeoTokenInput({
|
|
value = '',
|
|
onChange,
|
|
onBlur,
|
|
kind,
|
|
placeholder,
|
|
id,
|
|
'aria-describedby': ariaDescribedBy,
|
|
ref,
|
|
}: GeoTokenInputProps) {
|
|
const { t } = useTranslation();
|
|
const [browsing, setBrowsing] = useState(false);
|
|
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
|
|
const [checkFailed, setCheckFailed] = useState(false);
|
|
const validate = useValidateGeoTokens();
|
|
const { mutateAsync } = validate;
|
|
|
|
// An empty field has nothing to validate, so it clears during render rather
|
|
// than waiting a commit for the effect to catch up.
|
|
const isEmpty = parseTokens(value).length === 0;
|
|
const [wasEmpty, setWasEmpty] = useState(isEmpty);
|
|
if (isEmpty !== wasEmpty) {
|
|
setWasEmpty(isEmpty);
|
|
if (isEmpty) {
|
|
setIssues([]);
|
|
setCheckFailed(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const tokens = parseTokens(value);
|
|
if (tokens.length === 0) return;
|
|
let cancelled = false;
|
|
const timer = setTimeout(() => {
|
|
mutateAsync({ tokens, kind })
|
|
.then((found) => {
|
|
if (cancelled) return;
|
|
setIssues(found);
|
|
setCheckFailed(false);
|
|
})
|
|
// A rejected check says nothing about the tokens, so the warnings are
|
|
// dropped but replaced by a notice — silence here reads as "all valid".
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setIssues([]);
|
|
setCheckFailed(true);
|
|
});
|
|
}, VALIDATION_DELAY);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timer);
|
|
};
|
|
}, [value, kind, mutateAsync]);
|
|
|
|
return (
|
|
<>
|
|
<Space.Compact block>
|
|
<Input
|
|
ref={ref}
|
|
id={id}
|
|
aria-describedby={ariaDescribedBy}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
onBlur={onBlur}
|
|
/>
|
|
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
|
<Button
|
|
icon={<DatabaseOutlined aria-hidden />}
|
|
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
|
onClick={() => setBrowsing(true)}
|
|
/>
|
|
</Tooltip>
|
|
</Space.Compact>
|
|
{groupByReason(issues).map(([reason, tokens]) => (
|
|
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
|
|
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
|
|
</Typography.Text>
|
|
))}
|
|
{checkFailed && (
|
|
<Typography.Text type="secondary" className="geo-unknown-hint">
|
|
{t('pages.xray.geoBrowser.checkFailed')}
|
|
</Typography.Text>
|
|
)}
|
|
<GeoBrowserModal
|
|
open={browsing}
|
|
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
|
|
value={value}
|
|
onApply={(next) => {
|
|
onChange?.(next);
|
|
setBrowsing(false);
|
|
}}
|
|
onClose={() => setBrowsing(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
|
|
const grouped = new Map<string, string[]>();
|
|
for (const issue of issues) {
|
|
const tokens = grouped.get(issue.reason) ?? [];
|
|
tokens.push(issue.token);
|
|
grouped.set(issue.reason, tokens);
|
|
}
|
|
return [...grouped];
|
|
}
|