1
0

CommandPalette.tsx 26 KB

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