| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810 |
- 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,
- 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 />,
- },
- ];
- 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>
- );
- }
|