mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-10 16:05:01 +03:00
d1b60799ec
Invariant: an antd toast lives inside the React tree of the component that raised it, so it takes the theme from ConfigProvider and unmounts with it. Four components called antd's static message.* (Happ settings, TUIC form, routing tab, command palette); the other 40 use message.useMessage. A static toast renders in a detached root that RTL's cleanup never unmounts, so its rAF-driven close timer kept updating state for ~3s after a test file ended. happ-routing-editor.test.tsx logged 419 act() warnings, 226 of them after its last test; on a slow runner the worker closed first and vitest failed the job with "Closing rpc while onUserConsoleLog was pending" (CI runs 37075337071, 37303631843). In the product the same toasts ignored the dark/ultra theme. The command palette keeps its holder mounted while closed: restartXray closes the palette before its success toast. The Happ editor test now asserts its toast unmounts with the component (red on the static API), and no-static-message.test.ts fails on any static call in src. The spy that silenced message.success in happ-settings-presets.test.tsx intercepted nothing after the change and is gone.
821 lines
26 KiB
TypeScript
821 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
|
import {
|
|
ApiOutlined,
|
|
ApartmentOutlined,
|
|
CheckCircleFilled,
|
|
ClockCircleOutlined,
|
|
CloseCircleFilled,
|
|
CloudServerOutlined,
|
|
ClusterOutlined,
|
|
CodeOutlined,
|
|
CopyOutlined,
|
|
CrownOutlined,
|
|
DashboardOutlined,
|
|
DatabaseOutlined,
|
|
DiscordOutlined,
|
|
ExportOutlined,
|
|
FileTextOutlined,
|
|
GlobalOutlined,
|
|
ImportOutlined,
|
|
LoadingOutlined,
|
|
MailOutlined,
|
|
MessageOutlined,
|
|
MoonOutlined,
|
|
PlusOutlined,
|
|
ReloadOutlined,
|
|
SafetyOutlined,
|
|
SearchOutlined,
|
|
SettingOutlined,
|
|
SunOutlined,
|
|
SwapOutlined,
|
|
TagsOutlined,
|
|
TeamOutlined,
|
|
ToolOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils';
|
|
import { activateOnKey } from '@/utils/a11y';
|
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
|
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
|
import './CommandPalette.css';
|
|
|
|
interface PaletteItem {
|
|
id: string;
|
|
category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions';
|
|
title: string;
|
|
subtitle?: string;
|
|
keywords?: string[];
|
|
icon: ReactNode;
|
|
tag?: ReactNode;
|
|
action: () => void | Promise<void>;
|
|
secondaryAction?: {
|
|
label: string;
|
|
icon: ReactNode;
|
|
execute: (e: React.MouseEvent) => void;
|
|
};
|
|
}
|
|
|
|
export default function CommandPalette() {
|
|
const { t } = useTranslation();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
const navigate = useNavigate();
|
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
|
const { isOpen, close } = useCommandPalette();
|
|
const { allSetting } = useAllSettings();
|
|
const { data: inbounds = [] } = useInboundOptions();
|
|
|
|
const [query, setQuery] = useState('');
|
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({
|
|
query: '',
|
|
items: [],
|
|
});
|
|
const [loadingClients, setLoadingClients] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
|
const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K';
|
|
if ((e.metaKey || e.ctrlKey) && isK) {
|
|
e.preventDefault();
|
|
if (isOpen) {
|
|
close();
|
|
} else {
|
|
commandPaletteStore.open();
|
|
}
|
|
} else if (e.key === 'Escape' && isOpen) {
|
|
e.preventDefault();
|
|
close();
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleGlobalKeyDown, { capture: true });
|
|
return () => {
|
|
window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true });
|
|
};
|
|
}, [isOpen, close]);
|
|
|
|
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
|
if (isOpen !== prevIsOpen) {
|
|
setPrevIsOpen(isOpen);
|
|
if (!isOpen) {
|
|
setQuery('');
|
|
setDebouncedQuery('');
|
|
setClientSearch({ query: '', items: [] });
|
|
setActiveIndex(0);
|
|
setLoadingClients(false);
|
|
}
|
|
}
|
|
|
|
const [prevQuery, setPrevQuery] = useState(query);
|
|
if (query !== prevQuery) {
|
|
setPrevQuery(query);
|
|
setActiveIndex(0);
|
|
if (!query.trim()) {
|
|
setDebouncedQuery('');
|
|
setClientSearch({ query: '', items: [] });
|
|
setLoadingClients(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
|
|
const trimmed = query.trim();
|
|
if (!trimmed || trimmed === debouncedQuery) {
|
|
return;
|
|
}
|
|
|
|
const timer = window.setTimeout(() => {
|
|
setLoadingClients(true);
|
|
setDebouncedQuery(trimmed);
|
|
}, 300);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [isOpen, query, debouncedQuery]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || debouncedQuery.length < 1) {
|
|
return;
|
|
}
|
|
|
|
let isCurrent = true;
|
|
const controller = new AbortController();
|
|
|
|
HttpUtil.get(
|
|
`/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`,
|
|
undefined,
|
|
{ silent: true, signal: controller.signal },
|
|
)
|
|
.then((msg) => {
|
|
if (!isCurrent) return;
|
|
if (
|
|
msg?.success &&
|
|
msg?.obj &&
|
|
Array.isArray((msg.obj as { items?: ClientRecord[] }).items)
|
|
) {
|
|
setClientSearch({
|
|
query: debouncedQuery,
|
|
items: (msg.obj as { items: ClientRecord[] }).items,
|
|
});
|
|
} else {
|
|
setClientSearch({ query: debouncedQuery, items: [] });
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (isCurrent) setLoadingClients(false);
|
|
});
|
|
|
|
return () => {
|
|
isCurrent = false;
|
|
controller.abort();
|
|
};
|
|
}, [isOpen, debouncedQuery]);
|
|
|
|
const copySubscription = useCallback(
|
|
async (client: ClientRecord) => {
|
|
if (!client.subId || !allSetting.subURI) {
|
|
messageApi.warning(t('pages.clients.noSubId'));
|
|
return;
|
|
}
|
|
const link = `${allSetting.subURI}${client.subId}`;
|
|
const ok = await ClipboardManager.copyText(link);
|
|
if (ok) messageApi.success(t('copied'));
|
|
},
|
|
[allSetting.subURI, messageApi, t],
|
|
);
|
|
|
|
const restartXray = useCallback(async () => {
|
|
close();
|
|
const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
|
|
silentSuccess: true,
|
|
});
|
|
if (msg?.success) {
|
|
messageApi.success(t('commandPalette.restartXraySuccess'));
|
|
}
|
|
}, [close, messageApi, t]);
|
|
|
|
const cycleTheme = useCallback(() => {
|
|
if (!isDark) {
|
|
toggleTheme();
|
|
if (isUltra) toggleUltra();
|
|
} else if (!isUltra) {
|
|
toggleUltra();
|
|
} else {
|
|
toggleUltra();
|
|
toggleTheme();
|
|
}
|
|
close();
|
|
}, [isDark, isUltra, toggleTheme, toggleUltra, close]);
|
|
|
|
const trimmedQuery = query.trim();
|
|
const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;
|
|
const isClientSearching =
|
|
isOpen &&
|
|
trimmedQuery.length > 0 &&
|
|
(loadingClients || isDebouncing || clientSearch.query !== trimmedQuery);
|
|
|
|
const items = useMemo<PaletteItem[]>(() => {
|
|
const list: PaletteItem[] = [];
|
|
const q = query.trim().toLowerCase();
|
|
|
|
const matches = (title: string, subtitle?: string, keywords: string[] = []) => {
|
|
if (!q) return true;
|
|
if (title.toLowerCase().includes(q)) return true;
|
|
if (subtitle && subtitle.toLowerCase().includes(q)) return true;
|
|
return keywords.some((k) => k.toLowerCase().includes(q));
|
|
};
|
|
|
|
const trimmed = query.trim();
|
|
if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) {
|
|
clientSearch.items.forEach((c) => {
|
|
const up = Number(c.traffic?.up || 0);
|
|
const down = Number(c.traffic?.down || 0);
|
|
const total = Number(c.traffic?.total || c.totalGB || 0);
|
|
const trafficUsed = SizeFormatter.sizeFormat(up + down);
|
|
const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞';
|
|
const isOnline = c.enable !== false;
|
|
|
|
list.push({
|
|
id: `client-${c.id ?? c.email}`,
|
|
category: 'clients',
|
|
title: c.email,
|
|
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
|
|
icon: isOnline ? (
|
|
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
|
) : (
|
|
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
|
|
),
|
|
action: () => {
|
|
close();
|
|
navigate(`/clients?search=${encodeURIComponent(c.email)}`);
|
|
},
|
|
secondaryAction:
|
|
c.subId && allSetting.subURI
|
|
? {
|
|
label: t('commandPalette.copySubscription'),
|
|
icon: <CopyOutlined />,
|
|
execute: (e) => {
|
|
e.stopPropagation();
|
|
copySubscription(c);
|
|
},
|
|
}
|
|
: undefined,
|
|
});
|
|
});
|
|
}
|
|
|
|
const matchedInbounds = inbounds.filter((ib: InboundOption) => {
|
|
if (!q) return false;
|
|
return (
|
|
(ib.tag && ib.tag.toLowerCase().includes(q)) ||
|
|
(ib.remark && ib.remark.toLowerCase().includes(q)) ||
|
|
(ib.protocol && ib.protocol.toLowerCase().includes(q)) ||
|
|
(ib.port && String(ib.port).includes(q))
|
|
);
|
|
});
|
|
|
|
matchedInbounds.slice(0, 8).forEach((ib) => {
|
|
const tags: ReactNode[] = [];
|
|
if (ib.protocol) {
|
|
tags.push(
|
|
<Tag key="protocol" color="purple">
|
|
{ib.protocol}
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (ib.network) {
|
|
const n = ib.network.toLowerCase();
|
|
let netLabel = n.toUpperCase();
|
|
if (n === 'httpupgrade') netLabel = 'HTTPUpgrade';
|
|
else if (n === 'splithttp') netLabel = 'SplitHTTP';
|
|
else if (n === 'xhttp') netLabel = 'XHTTP';
|
|
tags.push(
|
|
<Tag key="network" color="green">
|
|
{netLabel}
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (ib.security && ib.security !== 'none') {
|
|
const s = ib.security.toLowerCase();
|
|
const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase();
|
|
tags.push(
|
|
<Tag key="security" color="blue">
|
|
{secLabel}
|
|
</Tag>,
|
|
);
|
|
}
|
|
|
|
list.push({
|
|
id: `inbound-${ib.id}`,
|
|
category: 'inbounds',
|
|
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
|
|
subtitle: `Port ${ib.port || ''}`,
|
|
icon: <ImportOutlined style={{ color: '#1677ff' }} />,
|
|
tag:
|
|
tags.length > 0 ? (
|
|
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
|
|
) : undefined,
|
|
action: () => {
|
|
close();
|
|
navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`);
|
|
},
|
|
});
|
|
});
|
|
|
|
const pages = [
|
|
{
|
|
path: '/',
|
|
title: t('menu.dashboard'),
|
|
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
|
icon: <DashboardOutlined />,
|
|
},
|
|
{
|
|
path: '/inbounds',
|
|
title: t('menu.inbounds'),
|
|
keywords: [
|
|
'inbounds',
|
|
'ports',
|
|
'vless',
|
|
'vmess',
|
|
'reality',
|
|
'trojan',
|
|
'shadowsocks',
|
|
'wireguard',
|
|
'hysteria',
|
|
],
|
|
icon: <ImportOutlined />,
|
|
},
|
|
{
|
|
path: '/clients',
|
|
title: t('menu.clients'),
|
|
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
|
|
icon: <TeamOutlined />,
|
|
},
|
|
{
|
|
path: '/groups',
|
|
title: t('menu.groups'),
|
|
keywords: ['groups', 'tags', 'batch'],
|
|
icon: <TagsOutlined />,
|
|
},
|
|
{
|
|
path: '/nodes',
|
|
title: t('menu.nodes'),
|
|
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
|
|
icon: <ClusterOutlined />,
|
|
},
|
|
{
|
|
path: '/hosts',
|
|
title: t('menu.hosts'),
|
|
keywords: ['hosts', 'sni', 'domains'],
|
|
icon: <GlobalOutlined />,
|
|
},
|
|
{
|
|
path: '/outbound',
|
|
title: t('menu.outbounds'),
|
|
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
|
icon: <ExportOutlined />,
|
|
},
|
|
{
|
|
path: '/routing',
|
|
title: t('menu.routing'),
|
|
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
|
icon: <SwapOutlined />,
|
|
},
|
|
{
|
|
path: '/settings',
|
|
title: t('menu.settings'),
|
|
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
|
icon: <SettingOutlined />,
|
|
},
|
|
{
|
|
path: '/xray',
|
|
title: t('menu.xray'),
|
|
keywords: ['xray', 'templates', 'balancer', 'dns'],
|
|
icon: <ToolOutlined />,
|
|
},
|
|
{
|
|
path: '/api-docs',
|
|
title: t('menu.apiDocs'),
|
|
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
|
icon: <ApiOutlined />,
|
|
},
|
|
{
|
|
path: '/sponsors',
|
|
title: t('menu.sponsors'),
|
|
keywords: ['sponsors', 'sponsor', 'partners'],
|
|
icon: <CrownOutlined />,
|
|
},
|
|
];
|
|
|
|
pages
|
|
.filter((p) => matches(p.title, undefined, p.keywords))
|
|
.forEach((p) => {
|
|
list.push({
|
|
id: `nav-${p.path}`,
|
|
category: 'navigation',
|
|
title: p.title,
|
|
keywords: p.keywords,
|
|
icon: p.icon,
|
|
action: () => {
|
|
close();
|
|
navigate(p.path);
|
|
},
|
|
});
|
|
});
|
|
|
|
const settingsSubSections = [
|
|
{
|
|
path: '/settings#general',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
|
|
subtitle: t('pages.settings.panelSettings'),
|
|
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
|
|
icon: <SettingOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#security',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
|
|
subtitle: t('pages.settings.securitySettings'),
|
|
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
|
|
icon: <SafetyOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#telegram',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
|
|
subtitle: t('pages.settings.TGBotSettings'),
|
|
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
|
|
icon: <MessageOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#email',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
|
|
subtitle: t('pages.settings.emailSettings'),
|
|
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
|
|
icon: <MailOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#discord',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
|
|
subtitle: t('pages.settings.discordSettings'),
|
|
keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
|
|
icon: <DiscordOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#subscription',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
|
|
subtitle: t('pages.settings.subSettings'),
|
|
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
|
|
icon: <CloudServerOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#subscription-formats',
|
|
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
|
|
subtitle: t('menu.subFormats'),
|
|
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
|
|
icon: <CodeOutlined />,
|
|
},
|
|
{
|
|
path: '/settings#subscription-balancers',
|
|
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
|
|
subtitle: t('pages.settings.subBalancers.menu'),
|
|
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
|
|
icon: <ApartmentOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#basic',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
|
|
subtitle: t('pages.xray.basicTemplate'),
|
|
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
|
|
icon: <ToolOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#basic',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`,
|
|
subtitle: t('pages.xray.metricsListen'),
|
|
keywords: [
|
|
'metrics',
|
|
'prometheus',
|
|
'statistics',
|
|
'listen',
|
|
'statsInbound',
|
|
'statsOutbound',
|
|
'metrics_out',
|
|
],
|
|
icon: <DashboardOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#basic',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
|
|
subtitle: t('pages.xray.connectionLimits'),
|
|
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
|
|
icon: <ClockCircleOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#basic',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
|
|
subtitle: t('pages.xray.logConfigs'),
|
|
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
|
|
icon: <FileTextOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#balancer',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
|
|
subtitle: t('pages.xray.Balancers'),
|
|
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
|
|
icon: <ClusterOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#dns',
|
|
title: `${t('menu.xray')} · DNS`,
|
|
subtitle: 'DNS',
|
|
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
|
|
icon: <DatabaseOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#outbound',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
|
|
subtitle: t('pages.xray.Outbounds'),
|
|
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
|
|
icon: <ExportOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#routing',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
|
|
subtitle: t('pages.xray.basicRouting'),
|
|
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
|
|
icon: <SwapOutlined />,
|
|
},
|
|
{
|
|
path: '/xray#advanced',
|
|
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
|
|
subtitle: t('pages.xray.advancedTemplate'),
|
|
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
|
|
icon: <CodeOutlined />,
|
|
},
|
|
];
|
|
|
|
settingsSubSections
|
|
.filter((s) => matches(s.title, s.subtitle, s.keywords))
|
|
.forEach((s) => {
|
|
list.push({
|
|
id: `setting-${s.path}-${s.title}`,
|
|
category: 'settings',
|
|
title: s.title,
|
|
subtitle: s.subtitle,
|
|
keywords: s.keywords,
|
|
icon: s.icon,
|
|
action: () => {
|
|
close();
|
|
navigate(s.path);
|
|
},
|
|
});
|
|
});
|
|
|
|
const actions: PaletteItem[] = [
|
|
{
|
|
id: 'act-restart-xray',
|
|
category: 'actions',
|
|
title: t('commandPalette.restartXray'),
|
|
subtitle: t('pages.index.restartXray'),
|
|
keywords: ['restart', 'xray restart', 'reboot xray'],
|
|
icon: <ReloadOutlined style={{ color: '#faad14' }} />,
|
|
action: restartXray,
|
|
},
|
|
{
|
|
id: 'act-cycle-theme',
|
|
category: 'actions',
|
|
title: t('menu.theme'),
|
|
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
|
|
keywords: ['theme', 'light', 'dark', 'ultra'],
|
|
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
|
|
action: cycleTheme,
|
|
},
|
|
{
|
|
id: 'act-add-inbound',
|
|
category: 'actions',
|
|
title: t('pages.inbounds.addInbound'),
|
|
subtitle: t('menu.inbounds'),
|
|
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
|
|
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
|
action: () => {
|
|
close();
|
|
navigate('/inbounds');
|
|
},
|
|
},
|
|
{
|
|
id: 'act-add-client',
|
|
category: 'actions',
|
|
title: t('pages.clients.addClient'),
|
|
subtitle: t('menu.clients'),
|
|
keywords: ['add client', 'create user', 'new client', 'new user'],
|
|
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
|
action: () => {
|
|
close();
|
|
navigate('/clients');
|
|
},
|
|
},
|
|
];
|
|
|
|
actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a));
|
|
|
|
return list;
|
|
}, [
|
|
query,
|
|
clientSearch,
|
|
inbounds,
|
|
isDark,
|
|
isUltra,
|
|
allSetting.subURI,
|
|
t,
|
|
close,
|
|
navigate,
|
|
copySubscription,
|
|
restartXray,
|
|
cycleTheme,
|
|
]);
|
|
|
|
const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
|
|
|
|
useEffect(() => {
|
|
if (!listRef.current) return;
|
|
const activeEl = listRef.current.querySelector(
|
|
`.command-palette-item[data-index="${clampedActiveIndex}"]`,
|
|
) as HTMLElement | null;
|
|
if (activeEl) {
|
|
activeEl.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [clampedActiveIndex]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const current = items[clampedActiveIndex];
|
|
if (current) current.action();
|
|
}
|
|
};
|
|
|
|
// Kept mounted while closed: restartXray closes the palette before its toast.
|
|
if (!isOpen) return messageContextHolder;
|
|
|
|
let lastCategory = '';
|
|
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
|
|
|
|
return (
|
|
<ConfigProvider theme={antdThemeConfig}>
|
|
{messageContextHolder}
|
|
<div
|
|
className={`command-palette-backdrop ${themeModeClass}`}
|
|
role="presentation"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) close();
|
|
}}
|
|
>
|
|
<div
|
|
className={`command-palette-modal ${themeModeClass}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('commandPalette.title')}
|
|
>
|
|
<div className="command-palette-header">
|
|
{isClientSearching ? (
|
|
<LoadingOutlined className="command-palette-search-icon spinning" />
|
|
) : (
|
|
<SearchOutlined className="command-palette-search-icon" />
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
className="command-palette-input"
|
|
type="text"
|
|
placeholder={t('commandPalette.placeholder')}
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
</div>
|
|
|
|
<div className="command-palette-body" ref={listRef}>
|
|
{!isClientSearching && items.length === 0 && (
|
|
<div className="command-palette-empty">{t('noData')}</div>
|
|
)}
|
|
|
|
{items.map((item, index) => {
|
|
const isFirstOfCategory = item.category !== lastCategory;
|
|
lastCategory = item.category;
|
|
|
|
const categoryLabel =
|
|
item.category === 'clients'
|
|
? t('menu.clients')
|
|
: item.category === 'inbounds'
|
|
? t('menu.inbounds')
|
|
: item.category === 'navigation'
|
|
? t('commandPalette.navigation')
|
|
: item.category === 'settings'
|
|
? t('commandPalette.settings') || t('menu.settings')
|
|
: t('commandPalette.actions');
|
|
|
|
return (
|
|
<div key={item.id} className="command-palette-group">
|
|
{isFirstOfCategory && (
|
|
<div className="command-palette-group-title">{categoryLabel}</div>
|
|
)}
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className={`command-palette-item ${index === clampedActiveIndex ? 'active' : ''}`}
|
|
data-index={index}
|
|
onClick={() => item.action()}
|
|
onKeyDown={(e) => {
|
|
// Enter on the nested copy button must activate that
|
|
// button, not the row it sits in.
|
|
if (e.target === e.currentTarget) activateOnKey(() => item.action())(e);
|
|
}}
|
|
onMouseEnter={() => setActiveIndex(index)}
|
|
>
|
|
<div className="command-palette-item-main">
|
|
<span className="command-palette-item-icon">{item.icon}</span>
|
|
<div className="command-palette-item-content">
|
|
<span className="command-palette-item-title">{item.title}</span>
|
|
{item.subtitle && (
|
|
<span className="command-palette-item-subtitle">{item.subtitle}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="command-palette-item-actions">
|
|
{item.tag}
|
|
{item.secondaryAction && (
|
|
<Tooltip
|
|
title={item.secondaryAction.label}
|
|
placement="top"
|
|
zIndex={2500}
|
|
rootClassName="command-palette-tooltip"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="command-palette-action-btn"
|
|
onClick={item.secondaryAction.execute}
|
|
aria-label={item.secondaryAction.label}
|
|
>
|
|
{item.secondaryAction.icon}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="command-palette-footer">
|
|
<div className="command-palette-kbd-group">
|
|
<span>
|
|
<kbd className="command-palette-kbd">↑</kbd>
|
|
<kbd className="command-palette-kbd">↓</kbd>
|
|
{t('commandPalette.navigate')}
|
|
</span>
|
|
<span>
|
|
<kbd className="command-palette-kbd">↵</kbd>
|
|
{t('commandPalette.select')}
|
|
</span>
|
|
<span>
|
|
<kbd className="command-palette-kbd">Esc</kbd>
|
|
{t('close')}
|
|
</span>
|
|
</div>
|
|
<span>3x-ui Command Palette</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ConfigProvider>
|
|
);
|
|
}
|