Files
router-lists-ui/frontend/src/components/CommandPalette.jsx
T

223 lines
8.1 KiB
React

import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
IconSearch,
IconWorld,
IconNetwork,
IconServer,
IconFilter,
IconHome,
IconDatabase,
IconCreditCard,
IconDownload,
IconKeyboard
} from '@tabler/icons-react'
/**
* Command Palette - глобальный поиск по командам (Ctrl+K)
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
// Создаем контекст для управления открытием/закрытием Command Palette
const CommandPaletteContext = { open: null };
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const navigate = useNavigate()
const inputRef = useRef(null)
// Сохраняем функцию открытия в контекст
useEffect(() => {
CommandPaletteContext.open = () => setIsOpen(true);
return () => {
CommandPaletteContext.open = null;
};
}, [])
// Список команд
const commands = [
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },
{ icon: IconFilter, label: 'Community', description: 'Справочник BGP Community', action: () => navigate('/communities'), keywords: ['community', 'справочник'] },
{ icon: IconServer, label: 'Серверы', description: 'Управление серверами', action: () => navigate('/servers'), keywords: ['серверы', 'servers'] },
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
]
// Фильтрация команд по поисковому запросу
const filteredCommands = searchTerm.trim() === ''
? commands
: commands.filter(cmd =>
cmd.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
cmd.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
cmd.keywords.some(kw => kw.includes(searchTerm.toLowerCase()))
)
// Открытие/закрытие по Ctrl+K или Cmd+K
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setIsOpen(prev => !prev)
}
if (e.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Навигация по списку стрелками
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(prev => Math.max(prev - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (filteredCommands[selectedIndex]) {
executeCommand(filteredCommands[selectedIndex])
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, selectedIndex, filteredCommands])
// Фокус на input при открытии
useEffect(() => {
if (isOpen) {
setSearchTerm('')
setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [isOpen])
const executeCommand = (command) => {
command.action()
setIsOpen(false)
}
if (!isOpen) return null
return (
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={() => setIsOpen(false)}
>
<div
className="modal-dialog modal-dialog-centered"
style={{ maxWidth: '600px' }}
onClick={(e) => e.stopPropagation()}
role="document"
>
<div className="modal-content" tabIndex={-1}>
{/* Search input */}
<div className="modal-header border-0 pb-0">
<div className="input-icon w-100">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
ref={inputRef}
type="text"
className="form-control form-control-lg border-0"
placeholder="Поиск команд..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
setSelectedIndex(0)
}}
/>
</div>
</div>
{/* Commands list */}
<div className="modal-body pt-2">
{filteredCommands.length === 0 ? (
<div className="text-center text-muted py-4">
<IconSearch size={48} className="mb-2 opacity-50" />
<div>Команды не найдены</div>
</div>
) : (
<div className="list-group list-group-flush">
{filteredCommands.map((cmd, index) => {
const Icon = cmd.icon
return (
<button
key={index}
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center${
index === selectedIndex ? ' active' : ''
}`}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Icon size={20} className="me-3" />
<div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div>
<small className="text-muted">{cmd.description}</small>
</div>
</button>
)
})}
</div>
)}
</div>
{/* Footer with hint */}
<div className="modal-footer border-0 pt-0">
<div className="text-muted small d-flex align-items-center gap-2">
<IconKeyboard size={16} />
<span>
<kbd></kbd> <kbd></kbd> навигация <kbd>Enter</kbd> выбрать <kbd>ESC</kbd> закрыть
</span>
</div>
</div>
</div>
</div>
</div>
)
}
/**
* Кнопка для открытия Command Palette через клик
*/
export function KeyboardShortcutsButton({ className = '' }) {
const handleClick = (e) => {
e.preventDefault();
if (CommandPaletteContext.open) {
CommandPaletteContext.open();
}
};
return (
<a
href="#"
className={className}
onClick={handleClick}
title="Командная палитра (Ctrl+K)"
>
<IconKeyboard size={20} />
</a>
);
}
export default CommandPalette