mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-02 04:02:07 +03:00
fd7b3559bc
Monthly sponsor placements need to change without cutting a panel release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in four slots: an overview banner, a rotating sidebar card (max three), the login page and a new Sponsors page that also lists open placements. An entry shows only while enable is not false and until is in the future; links must be https and logos are png/webp/jpg by name only. The list is cached for an hour and the last good copy survives upstream failures; logos are proxied through /sponsors/logo/:name with failures cached, so CSP stays 'self' and admin browsers never reach a third party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads a sibling ../sponsors/3X checkout so edits can be previewed before push.
818 lines
26 KiB
TypeScript
818 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 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) {
|
|
message.warning(t('pages.clients.noSubId'));
|
|
return;
|
|
}
|
|
const link = `${allSetting.subURI}${client.subId}`;
|
|
const ok = await ClipboardManager.copyText(link);
|
|
if (ok) message.success(t('copied'));
|
|
},
|
|
[allSetting.subURI, t],
|
|
);
|
|
|
|
const restartXray = useCallback(async () => {
|
|
close();
|
|
const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
|
|
silentSuccess: true,
|
|
});
|
|
if (msg?.success) {
|
|
message.success(t('commandPalette.restartXraySuccess'));
|
|
}
|
|
}, [close, 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();
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
let lastCategory = '';
|
|
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
|
|
|
|
return (
|
|
<ConfigProvider theme={antdThemeConfig}>
|
|
<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>
|
|
);
|
|
}
|