CommandPalette.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import type { ReactNode } from 'react';
  3. import { useNavigate } from 'react-router';
  4. import { useTranslation } from 'react-i18next';
  5. import { ConfigProvider, Tag, Tooltip, message } from 'antd';
  6. import {
  7. ApiOutlined,
  8. ApartmentOutlined,
  9. CheckCircleFilled,
  10. ClockCircleOutlined,
  11. CloseCircleFilled,
  12. CloudServerOutlined,
  13. ClusterOutlined,
  14. CodeOutlined,
  15. CopyOutlined,
  16. CrownOutlined,
  17. DashboardOutlined,
  18. DatabaseOutlined,
  19. DiscordOutlined,
  20. ExportOutlined,
  21. FileTextOutlined,
  22. GlobalOutlined,
  23. ImportOutlined,
  24. LoadingOutlined,
  25. MailOutlined,
  26. MessageOutlined,
  27. MoonOutlined,
  28. PlusOutlined,
  29. ReloadOutlined,
  30. SafetyOutlined,
  31. SearchOutlined,
  32. SettingOutlined,
  33. SunOutlined,
  34. SwapOutlined,
  35. TagsOutlined,
  36. TeamOutlined,
  37. ToolOutlined,
  38. } from '@ant-design/icons';
  39. import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils';
  40. import { activateOnKey } from '@/utils/a11y';
  41. import { useInboundOptions } from '@/api/queries/useInboundOptions';
  42. import { useAllSettings } from '@/api/queries/useAllSettings';
  43. import { useTheme } from '@/hooks/useTheme';
  44. import type { ClientRecord, InboundOption } from '@/schemas/client';
  45. import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
  46. import './CommandPalette.css';
  47. interface PaletteItem {
  48. id: string;
  49. category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions';
  50. title: string;
  51. subtitle?: string;
  52. keywords?: string[];
  53. icon: ReactNode;
  54. tag?: ReactNode;
  55. action: () => void | Promise<void>;
  56. secondaryAction?: {
  57. label: string;
  58. icon: ReactNode;
  59. execute: (e: React.MouseEvent) => void;
  60. };
  61. }
  62. export default function CommandPalette() {
  63. const { t } = useTranslation();
  64. const [messageApi, messageContextHolder] = message.useMessage();
  65. const navigate = useNavigate();
  66. const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
  67. const { isOpen, close } = useCommandPalette();
  68. const { allSetting } = useAllSettings();
  69. const { data: inbounds = [] } = useInboundOptions();
  70. const [query, setQuery] = useState('');
  71. const [debouncedQuery, setDebouncedQuery] = useState('');
  72. const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({
  73. query: '',
  74. items: [],
  75. });
  76. const [loadingClients, setLoadingClients] = useState(false);
  77. const [activeIndex, setActiveIndex] = useState(0);
  78. const inputRef = useRef<HTMLInputElement>(null);
  79. const listRef = useRef<HTMLDivElement>(null);
  80. useEffect(() => {
  81. function handleGlobalKeyDown(e: KeyboardEvent) {
  82. const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K';
  83. if ((e.metaKey || e.ctrlKey) && isK) {
  84. e.preventDefault();
  85. if (isOpen) {
  86. close();
  87. } else {
  88. commandPaletteStore.open();
  89. }
  90. } else if (e.key === 'Escape' && isOpen) {
  91. e.preventDefault();
  92. close();
  93. }
  94. }
  95. window.addEventListener('keydown', handleGlobalKeyDown, { capture: true });
  96. return () => {
  97. window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true });
  98. };
  99. }, [isOpen, close]);
  100. const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
  101. if (isOpen !== prevIsOpen) {
  102. setPrevIsOpen(isOpen);
  103. if (!isOpen) {
  104. setQuery('');
  105. setDebouncedQuery('');
  106. setClientSearch({ query: '', items: [] });
  107. setActiveIndex(0);
  108. setLoadingClients(false);
  109. }
  110. }
  111. const [prevQuery, setPrevQuery] = useState(query);
  112. if (query !== prevQuery) {
  113. setPrevQuery(query);
  114. setActiveIndex(0);
  115. if (!query.trim()) {
  116. setDebouncedQuery('');
  117. setClientSearch({ query: '', items: [] });
  118. setLoadingClients(false);
  119. }
  120. }
  121. useEffect(() => {
  122. if (isOpen) {
  123. setTimeout(() => inputRef.current?.focus(), 50);
  124. }
  125. }, [isOpen]);
  126. useEffect(() => {
  127. if (!isOpen) {
  128. return;
  129. }
  130. const trimmed = query.trim();
  131. if (!trimmed || trimmed === debouncedQuery) {
  132. return;
  133. }
  134. const timer = window.setTimeout(() => {
  135. setLoadingClients(true);
  136. setDebouncedQuery(trimmed);
  137. }, 300);
  138. return () => {
  139. window.clearTimeout(timer);
  140. };
  141. }, [isOpen, query, debouncedQuery]);
  142. useEffect(() => {
  143. if (!isOpen || debouncedQuery.length < 1) {
  144. return;
  145. }
  146. let isCurrent = true;
  147. const controller = new AbortController();
  148. HttpUtil.get(
  149. `/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`,
  150. undefined,
  151. { silent: true, signal: controller.signal },
  152. )
  153. .then((msg) => {
  154. if (!isCurrent) return;
  155. if (
  156. msg?.success &&
  157. msg?.obj &&
  158. Array.isArray((msg.obj as { items?: ClientRecord[] }).items)
  159. ) {
  160. setClientSearch({
  161. query: debouncedQuery,
  162. items: (msg.obj as { items: ClientRecord[] }).items,
  163. });
  164. } else {
  165. setClientSearch({ query: debouncedQuery, items: [] });
  166. }
  167. })
  168. .finally(() => {
  169. if (isCurrent) setLoadingClients(false);
  170. });
  171. return () => {
  172. isCurrent = false;
  173. controller.abort();
  174. };
  175. }, [isOpen, debouncedQuery]);
  176. const copySubscription = useCallback(
  177. async (client: ClientRecord) => {
  178. if (!client.subId || !allSetting.subURI) {
  179. messageApi.warning(t('pages.clients.noSubId'));
  180. return;
  181. }
  182. const link = `${allSetting.subURI}${client.subId}`;
  183. const ok = await ClipboardManager.copyText(link);
  184. if (ok) messageApi.success(t('copied'));
  185. },
  186. [allSetting.subURI, messageApi, t],
  187. );
  188. const restartXray = useCallback(async () => {
  189. close();
  190. const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
  191. silentSuccess: true,
  192. });
  193. if (msg?.success) {
  194. messageApi.success(t('commandPalette.restartXraySuccess'));
  195. }
  196. }, [close, messageApi, t]);
  197. const cycleTheme = useCallback(() => {
  198. if (!isDark) {
  199. toggleTheme();
  200. if (isUltra) toggleUltra();
  201. } else if (!isUltra) {
  202. toggleUltra();
  203. } else {
  204. toggleUltra();
  205. toggleTheme();
  206. }
  207. close();
  208. }, [isDark, isUltra, toggleTheme, toggleUltra, close]);
  209. const trimmedQuery = query.trim();
  210. const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;
  211. const isClientSearching =
  212. isOpen &&
  213. trimmedQuery.length > 0 &&
  214. (loadingClients || isDebouncing || clientSearch.query !== trimmedQuery);
  215. const items = useMemo<PaletteItem[]>(() => {
  216. const list: PaletteItem[] = [];
  217. const q = query.trim().toLowerCase();
  218. const matches = (title: string, subtitle?: string, keywords: string[] = []) => {
  219. if (!q) return true;
  220. if (title.toLowerCase().includes(q)) return true;
  221. if (subtitle && subtitle.toLowerCase().includes(q)) return true;
  222. return keywords.some((k) => k.toLowerCase().includes(q));
  223. };
  224. const trimmed = query.trim();
  225. if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) {
  226. clientSearch.items.forEach((c) => {
  227. const up = Number(c.traffic?.up || 0);
  228. const down = Number(c.traffic?.down || 0);
  229. const total = Number(c.traffic?.total || c.totalGB || 0);
  230. const trafficUsed = SizeFormatter.sizeFormat(up + down);
  231. const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞';
  232. const isOnline = c.enable !== false;
  233. list.push({
  234. id: `client-${c.id ?? c.email}`,
  235. category: 'clients',
  236. title: c.email,
  237. subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
  238. icon: isOnline ? (
  239. <CheckCircleFilled style={{ color: '#52c41a' }} />
  240. ) : (
  241. <CloseCircleFilled style={{ color: '#ff4d4f' }} />
  242. ),
  243. action: () => {
  244. close();
  245. navigate(`/clients?search=${encodeURIComponent(c.email)}`);
  246. },
  247. secondaryAction:
  248. c.subId && allSetting.subURI
  249. ? {
  250. label: t('commandPalette.copySubscription'),
  251. icon: <CopyOutlined />,
  252. execute: (e) => {
  253. e.stopPropagation();
  254. copySubscription(c);
  255. },
  256. }
  257. : undefined,
  258. });
  259. });
  260. }
  261. const matchedInbounds = inbounds.filter((ib: InboundOption) => {
  262. if (!q) return false;
  263. return (
  264. (ib.tag && ib.tag.toLowerCase().includes(q)) ||
  265. (ib.remark && ib.remark.toLowerCase().includes(q)) ||
  266. (ib.protocol && ib.protocol.toLowerCase().includes(q)) ||
  267. (ib.port && String(ib.port).includes(q))
  268. );
  269. });
  270. matchedInbounds.slice(0, 8).forEach((ib) => {
  271. const tags: ReactNode[] = [];
  272. if (ib.protocol) {
  273. tags.push(
  274. <Tag key="protocol" color="purple">
  275. {ib.protocol}
  276. </Tag>,
  277. );
  278. }
  279. if (ib.network) {
  280. const n = ib.network.toLowerCase();
  281. let netLabel = n.toUpperCase();
  282. if (n === 'httpupgrade') netLabel = 'HTTPUpgrade';
  283. else if (n === 'splithttp') netLabel = 'SplitHTTP';
  284. else if (n === 'xhttp') netLabel = 'XHTTP';
  285. tags.push(
  286. <Tag key="network" color="green">
  287. {netLabel}
  288. </Tag>,
  289. );
  290. }
  291. if (ib.security && ib.security !== 'none') {
  292. const s = ib.security.toLowerCase();
  293. const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase();
  294. tags.push(
  295. <Tag key="security" color="blue">
  296. {secLabel}
  297. </Tag>,
  298. );
  299. }
  300. list.push({
  301. id: `inbound-${ib.id}`,
  302. category: 'inbounds',
  303. title: ib.remark || ib.tag || `Inbound #${ib.id}`,
  304. subtitle: `Port ${ib.port || ''}`,
  305. icon: <ImportOutlined style={{ color: '#1677ff' }} />,
  306. tag:
  307. tags.length > 0 ? (
  308. <div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
  309. ) : undefined,
  310. action: () => {
  311. close();
  312. navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`);
  313. },
  314. });
  315. });
  316. const pages = [
  317. {
  318. path: '/',
  319. title: t('menu.dashboard'),
  320. keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
  321. icon: <DashboardOutlined />,
  322. },
  323. {
  324. path: '/inbounds',
  325. title: t('menu.inbounds'),
  326. keywords: [
  327. 'inbounds',
  328. 'ports',
  329. 'vless',
  330. 'vmess',
  331. 'reality',
  332. 'trojan',
  333. 'shadowsocks',
  334. 'wireguard',
  335. 'hysteria',
  336. 'masque',
  337. ],
  338. icon: <ImportOutlined />,
  339. },
  340. {
  341. path: '/clients',
  342. title: t('menu.clients'),
  343. keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
  344. icon: <TeamOutlined />,
  345. },
  346. {
  347. path: '/groups',
  348. title: t('menu.groups'),
  349. keywords: ['groups', 'tags', 'batch'],
  350. icon: <TagsOutlined />,
  351. },
  352. {
  353. path: '/nodes',
  354. title: t('menu.nodes'),
  355. keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
  356. icon: <ClusterOutlined />,
  357. },
  358. {
  359. path: '/hosts',
  360. title: t('menu.hosts'),
  361. keywords: ['hosts', 'sni', 'domains'],
  362. icon: <GlobalOutlined />,
  363. },
  364. {
  365. path: '/outbound',
  366. title: t('menu.outbounds'),
  367. keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
  368. icon: <ExportOutlined />,
  369. },
  370. {
  371. path: '/routing',
  372. title: t('menu.routing'),
  373. keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
  374. icon: <SwapOutlined />,
  375. },
  376. {
  377. path: '/settings',
  378. title: t('menu.settings'),
  379. keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
  380. icon: <SettingOutlined />,
  381. },
  382. {
  383. path: '/xray',
  384. title: t('menu.xray'),
  385. keywords: ['xray', 'templates', 'balancer', 'dns'],
  386. icon: <ToolOutlined />,
  387. },
  388. {
  389. path: '/api-docs',
  390. title: t('menu.apiDocs'),
  391. keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
  392. icon: <ApiOutlined />,
  393. },
  394. {
  395. path: '/sponsors',
  396. title: t('menu.sponsors'),
  397. keywords: ['sponsors', 'sponsor', 'partners'],
  398. icon: <CrownOutlined />,
  399. },
  400. ];
  401. pages
  402. .filter((p) => matches(p.title, undefined, p.keywords))
  403. .forEach((p) => {
  404. list.push({
  405. id: `nav-${p.path}`,
  406. category: 'navigation',
  407. title: p.title,
  408. keywords: p.keywords,
  409. icon: p.icon,
  410. action: () => {
  411. close();
  412. navigate(p.path);
  413. },
  414. });
  415. });
  416. const settingsSubSections = [
  417. {
  418. path: '/settings#general',
  419. title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
  420. subtitle: t('pages.settings.panelSettings'),
  421. keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
  422. icon: <SettingOutlined />,
  423. },
  424. {
  425. path: '/settings#security',
  426. title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
  427. subtitle: t('pages.settings.securitySettings'),
  428. keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
  429. icon: <SafetyOutlined />,
  430. },
  431. {
  432. path: '/settings#telegram',
  433. title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
  434. subtitle: t('pages.settings.TGBotSettings'),
  435. keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
  436. icon: <MessageOutlined />,
  437. },
  438. {
  439. path: '/settings#email',
  440. title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
  441. subtitle: t('pages.settings.emailSettings'),
  442. keywords: ['email', 'smtp', 'mail', 'crash alerts'],
  443. icon: <MailOutlined />,
  444. },
  445. {
  446. path: '/settings#discord',
  447. title: `${t('menu.settings')} · ${t('pages.settings.discordSettings')}`,
  448. subtitle: t('pages.settings.discordSettings'),
  449. keywords: ['discord', 'bot', 'channel', 'notifications', 'alerts'],
  450. icon: <DiscordOutlined />,
  451. },
  452. {
  453. path: '/settings#subscription',
  454. title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
  455. subtitle: t('pages.settings.subSettings'),
  456. keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
  457. icon: <CloudServerOutlined />,
  458. },
  459. {
  460. path: '/settings#subscription-formats',
  461. title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
  462. subtitle: t('menu.subFormats'),
  463. keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
  464. icon: <CodeOutlined />,
  465. },
  466. {
  467. path: '/settings#subscription-balancers',
  468. title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
  469. subtitle: t('pages.settings.subBalancers.menu'),
  470. keywords: ['balancers', 'sub balancers', 'balancer nodes'],
  471. icon: <ApartmentOutlined />,
  472. },
  473. {
  474. path: '/xray#basic',
  475. title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
  476. subtitle: t('pages.xray.basicTemplate'),
  477. keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
  478. icon: <ToolOutlined />,
  479. },
  480. {
  481. path: '/xray#basic',
  482. title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`,
  483. subtitle: t('pages.xray.metricsListen'),
  484. keywords: [
  485. 'metrics',
  486. 'prometheus',
  487. 'statistics',
  488. 'listen',
  489. 'statsInbound',
  490. 'statsOutbound',
  491. 'metrics_out',
  492. ],
  493. icon: <DashboardOutlined />,
  494. },
  495. {
  496. path: '/xray#basic',
  497. title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
  498. subtitle: t('pages.xray.connectionLimits'),
  499. keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
  500. icon: <ClockCircleOutlined />,
  501. },
  502. {
  503. path: '/xray#basic',
  504. title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
  505. subtitle: t('pages.xray.logConfigs'),
  506. keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
  507. icon: <FileTextOutlined />,
  508. },
  509. {
  510. path: '/xray#balancer',
  511. title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
  512. subtitle: t('pages.xray.Balancers'),
  513. keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
  514. icon: <ClusterOutlined />,
  515. },
  516. {
  517. path: '/xray#dns',
  518. title: `${t('menu.xray')} · DNS`,
  519. subtitle: 'DNS',
  520. keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
  521. icon: <DatabaseOutlined />,
  522. },
  523. {
  524. path: '/xray#outbound',
  525. title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
  526. subtitle: t('pages.xray.Outbounds'),
  527. keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
  528. icon: <ExportOutlined />,
  529. },
  530. {
  531. path: '/xray#routing',
  532. title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
  533. subtitle: t('pages.xray.basicRouting'),
  534. keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
  535. icon: <SwapOutlined />,
  536. },
  537. {
  538. path: '/xray#advanced',
  539. title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
  540. subtitle: t('pages.xray.advancedTemplate'),
  541. keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
  542. icon: <CodeOutlined />,
  543. },
  544. ];
  545. settingsSubSections
  546. .filter((s) => matches(s.title, s.subtitle, s.keywords))
  547. .forEach((s) => {
  548. list.push({
  549. id: `setting-${s.path}-${s.title}`,
  550. category: 'settings',
  551. title: s.title,
  552. subtitle: s.subtitle,
  553. keywords: s.keywords,
  554. icon: s.icon,
  555. action: () => {
  556. close();
  557. navigate(s.path);
  558. },
  559. });
  560. });
  561. const actions: PaletteItem[] = [
  562. {
  563. id: 'act-restart-xray',
  564. category: 'actions',
  565. title: t('commandPalette.restartXray'),
  566. subtitle: t('pages.index.restartXray'),
  567. keywords: ['restart', 'xray restart', 'reboot xray'],
  568. icon: <ReloadOutlined style={{ color: '#faad14' }} />,
  569. action: restartXray,
  570. },
  571. {
  572. id: 'act-cycle-theme',
  573. category: 'actions',
  574. title: t('menu.theme'),
  575. subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
  576. keywords: ['theme', 'light', 'dark', 'ultra'],
  577. icon: isDark ? <SunOutlined /> : <MoonOutlined />,
  578. action: cycleTheme,
  579. },
  580. {
  581. id: 'act-add-inbound',
  582. category: 'actions',
  583. title: t('pages.inbounds.addInbound'),
  584. subtitle: t('menu.inbounds'),
  585. keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
  586. icon: <PlusOutlined style={{ color: '#52c41a' }} />,
  587. action: () => {
  588. close();
  589. navigate('/inbounds');
  590. },
  591. },
  592. {
  593. id: 'act-add-client',
  594. category: 'actions',
  595. title: t('pages.clients.addClient'),
  596. subtitle: t('menu.clients'),
  597. keywords: ['add client', 'create user', 'new client', 'new user'],
  598. icon: <PlusOutlined style={{ color: '#52c41a' }} />,
  599. action: () => {
  600. close();
  601. navigate('/clients');
  602. },
  603. },
  604. ];
  605. actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a));
  606. return list;
  607. }, [
  608. query,
  609. clientSearch,
  610. inbounds,
  611. isDark,
  612. isUltra,
  613. allSetting.subURI,
  614. t,
  615. close,
  616. navigate,
  617. copySubscription,
  618. restartXray,
  619. cycleTheme,
  620. ]);
  621. const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
  622. useEffect(() => {
  623. if (!listRef.current) return;
  624. const activeEl = listRef.current.querySelector(
  625. `.command-palette-item[data-index="${clampedActiveIndex}"]`,
  626. ) as HTMLElement | null;
  627. if (activeEl) {
  628. activeEl.scrollIntoView({ block: 'nearest' });
  629. }
  630. }, [clampedActiveIndex]);
  631. const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  632. if (e.key === 'ArrowDown') {
  633. e.preventDefault();
  634. setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0));
  635. } else if (e.key === 'ArrowUp') {
  636. e.preventDefault();
  637. setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0));
  638. } else if (e.key === 'Enter') {
  639. e.preventDefault();
  640. const current = items[clampedActiveIndex];
  641. if (current) current.action();
  642. }
  643. };
  644. // Kept mounted while closed: restartXray closes the palette before its toast.
  645. if (!isOpen) return messageContextHolder;
  646. let lastCategory = '';
  647. const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
  648. return (
  649. <ConfigProvider theme={antdThemeConfig}>
  650. {messageContextHolder}
  651. <div
  652. className={`command-palette-backdrop ${themeModeClass}`}
  653. role="presentation"
  654. onClick={(e) => {
  655. if (e.target === e.currentTarget) close();
  656. }}
  657. >
  658. <div
  659. className={`command-palette-modal ${themeModeClass}`}
  660. role="dialog"
  661. aria-modal="true"
  662. aria-label={t('commandPalette.title')}
  663. >
  664. <div className="command-palette-header">
  665. {isClientSearching ? (
  666. <LoadingOutlined className="command-palette-search-icon spinning" />
  667. ) : (
  668. <SearchOutlined className="command-palette-search-icon" />
  669. )}
  670. <input
  671. ref={inputRef}
  672. className="command-palette-input"
  673. type="text"
  674. placeholder={t('commandPalette.placeholder')}
  675. value={query}
  676. onChange={(e) => {
  677. setQuery(e.target.value);
  678. }}
  679. onKeyDown={handleKeyDown}
  680. />
  681. </div>
  682. <div className="command-palette-body" ref={listRef}>
  683. {!isClientSearching && items.length === 0 && (
  684. <div className="command-palette-empty">{t('noData')}</div>
  685. )}
  686. {items.map((item, index) => {
  687. const isFirstOfCategory = item.category !== lastCategory;
  688. lastCategory = item.category;
  689. const categoryLabel =
  690. item.category === 'clients'
  691. ? t('menu.clients')
  692. : item.category === 'inbounds'
  693. ? t('menu.inbounds')
  694. : item.category === 'navigation'
  695. ? t('commandPalette.navigation')
  696. : item.category === 'settings'
  697. ? t('commandPalette.settings') || t('menu.settings')
  698. : t('commandPalette.actions');
  699. return (
  700. <div key={item.id} className="command-palette-group">
  701. {isFirstOfCategory && (
  702. <div className="command-palette-group-title">{categoryLabel}</div>
  703. )}
  704. <div
  705. role="button"
  706. tabIndex={0}
  707. className={`command-palette-item ${index === clampedActiveIndex ? 'active' : ''}`}
  708. data-index={index}
  709. onClick={() => item.action()}
  710. onKeyDown={(e) => {
  711. // Enter on the nested copy button must activate that
  712. // button, not the row it sits in.
  713. if (e.target === e.currentTarget) activateOnKey(() => item.action())(e);
  714. }}
  715. onMouseEnter={() => setActiveIndex(index)}
  716. >
  717. <div className="command-palette-item-main">
  718. <span className="command-palette-item-icon">{item.icon}</span>
  719. <div className="command-palette-item-content">
  720. <span className="command-palette-item-title">{item.title}</span>
  721. {item.subtitle && (
  722. <span className="command-palette-item-subtitle">{item.subtitle}</span>
  723. )}
  724. </div>
  725. </div>
  726. <div className="command-palette-item-actions">
  727. {item.tag}
  728. {item.secondaryAction && (
  729. <Tooltip
  730. title={item.secondaryAction.label}
  731. placement="top"
  732. zIndex={2500}
  733. rootClassName="command-palette-tooltip"
  734. >
  735. <button
  736. type="button"
  737. className="command-palette-action-btn"
  738. onClick={item.secondaryAction.execute}
  739. aria-label={item.secondaryAction.label}
  740. >
  741. {item.secondaryAction.icon}
  742. </button>
  743. </Tooltip>
  744. )}
  745. </div>
  746. </div>
  747. </div>
  748. );
  749. })}
  750. </div>
  751. <div className="command-palette-footer">
  752. <div className="command-palette-kbd-group">
  753. <span>
  754. <kbd className="command-palette-kbd">↑</kbd>
  755. <kbd className="command-palette-kbd">↓</kbd>
  756. {t('commandPalette.navigate')}
  757. </span>
  758. <span>
  759. <kbd className="command-palette-kbd">↵</kbd>
  760. {t('commandPalette.select')}
  761. </span>
  762. <span>
  763. <kbd className="command-palette-kbd">Esc</kbd>
  764. {t('close')}
  765. </span>
  766. </div>
  767. <span>3x-ui Command Palette</span>
  768. </div>
  769. </div>
  770. </div>
  771. </ConfigProvider>
  772. );
  773. }