mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-27 02:55:22 +08:00
* 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.
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Input, Space } from 'antd';
|
|
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
|
|
|
import { InputAddon } from '@/components/ui';
|
|
|
|
// Reusable header-map editor. Handles the two wire shapes Xray uses for
|
|
// HTTP-style header maps:
|
|
//
|
|
// v1: { 'Content-Type': 'application/json', 'X-Custom': 'value' }
|
|
// Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
|
|
// name.
|
|
//
|
|
// v2: { 'Accept': ['text/html', 'application/json'],
|
|
// 'X-Forwarded': ['1.2.3.4'] }
|
|
// Used by TCP HTTP camouflage request/response. Each header can
|
|
// repeat (RFC 7230 §3.2.2).
|
|
//
|
|
// Internal state is always the flat list-of-rows shape regardless of
|
|
// mode. Conversion to/from the wire shape happens at the value/onChange
|
|
// boundary so consumers can bind straight to a Form.Item without any
|
|
// extra transforms on their side.
|
|
|
|
export type HeaderMapMode = 'v1' | 'v2';
|
|
|
|
export type HeaderMapValue = Record<string, string> | Record<string, string[]> | undefined;
|
|
|
|
interface HeaderRow {
|
|
name: string;
|
|
value: string;
|
|
}
|
|
|
|
interface HeaderMapEditorProps {
|
|
mode: HeaderMapMode;
|
|
value?: HeaderMapValue;
|
|
onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
|
|
}
|
|
|
|
function mapToRows(value: HeaderMapValue): HeaderRow[] {
|
|
if (!value || typeof value !== 'object') return [];
|
|
const out: HeaderRow[] = [];
|
|
for (const [name, raw] of Object.entries(value)) {
|
|
if (Array.isArray(raw)) {
|
|
for (const v of raw) {
|
|
out.push({ name, value: typeof v === 'string' ? v : String(v) });
|
|
}
|
|
} else if (typeof raw === 'string') {
|
|
out.push({ name, value: raw });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rowsToMap(
|
|
rows: HeaderRow[],
|
|
mode: HeaderMapMode,
|
|
): Record<string, string> | Record<string, string[]> {
|
|
if (mode === 'v1') {
|
|
const map: Record<string, string> = {};
|
|
for (const r of rows) {
|
|
if (!r.name) continue;
|
|
map[r.name] = r.value ?? '';
|
|
}
|
|
return map;
|
|
}
|
|
const map: Record<string, string[]> = {};
|
|
for (const r of rows) {
|
|
if (!r.name) continue;
|
|
const list = map[r.name] ?? [];
|
|
list.push(r.value ?? '');
|
|
map[r.name] = list;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
|
|
const { t } = useTranslation();
|
|
// Local state holds rows including blanks. Without it, addRow() would
|
|
// append a {name:'', value:''} that rowsToMap immediately filters out
|
|
// before reaching the form, so the new row would never reach UI. The
|
|
// form-bound map only sees rows with non-empty names; blank rows live
|
|
// here until the user fills them in.
|
|
const [rows, setRows] = useState<HeaderRow[]>(() => mapToRows(value));
|
|
const lastEmittedRef = useRef<string>(JSON.stringify(rowsToMap(rows, mode)));
|
|
|
|
// Re-sync local rows when the form value changes from outside (modal
|
|
// re-open with edit data, JSON tab edits, etc.) but not when it's our
|
|
// own emission echoing back.
|
|
useEffect(() => {
|
|
const incoming = JSON.stringify(value ?? {});
|
|
if (incoming === lastEmittedRef.current) return;
|
|
setRows(mapToRows(value));
|
|
lastEmittedRef.current = incoming;
|
|
}, [value]);
|
|
|
|
function commit(next: HeaderRow[]) {
|
|
setRows(next);
|
|
const map = rowsToMap(next, mode);
|
|
lastEmittedRef.current = JSON.stringify(map);
|
|
onChange?.(map);
|
|
}
|
|
|
|
function setRow(index: number, patch: Partial<HeaderRow>) {
|
|
const next = rows.slice();
|
|
next[index] = { ...next[index], ...patch };
|
|
commit(next);
|
|
}
|
|
|
|
function addRow() {
|
|
commit([...rows, { name: '', value: '' }]);
|
|
}
|
|
|
|
function removeRow(index: number) {
|
|
const next = rows.slice();
|
|
next.splice(index, 1);
|
|
commit(next);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{rows.map((row, idx) => (
|
|
<Space.Compact key={idx} block className="mb-8">
|
|
<InputAddon>{`${idx + 1}`}</InputAddon>
|
|
<Input
|
|
value={row.name}
|
|
placeholder="Name"
|
|
aria-label={t('pages.inbounds.form.headerName')}
|
|
onChange={(e) => setRow(idx, { name: e.target.value })}
|
|
/>
|
|
<Input
|
|
value={row.value}
|
|
placeholder="Value"
|
|
aria-label={t('pages.inbounds.form.headerValue')}
|
|
onChange={(e) => setRow(idx, { value: e.target.value })}
|
|
/>
|
|
<Button
|
|
aria-label={t('remove')}
|
|
icon={<MinusOutlined aria-hidden />}
|
|
onClick={() => removeRow(idx)}
|
|
/>
|
|
</Space.Compact>
|
|
))}
|
|
<Button size="small" type="primary" icon={<PlusOutlined aria-hidden />} onClick={addRow}>
|
|
Add
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|