feat: Добавить новые компоненты и улучшения UX/UI, включая Breadcrumbs с динамическими фильтрами, ToastContainer для уведомлений и CommandPalette для глобального поиска. Обновить TableSkeleton с анимацией и добавить новые skeleton компоненты для карточек, списков и форм. Обновить документацию по новым компонентам и улучшениям.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s

This commit is contained in:
2025-10-02 18:02:22 +07:00
parent 495063a510
commit 339144fb11
12 changed files with 1950 additions and 24 deletions
+256
View File
@@ -0,0 +1,256 @@
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)
*/
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const navigate = useNavigate()
const inputRef = useRef(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 (
<>
{/* Backdrop */}
<div
className="modal-backdrop fade show"
onClick={() => setIsOpen(false)}
style={{ zIndex: 1050 }}
></div>
{/* Modal */}
<div
className="modal fade show d-block"
tabIndex="-1"
style={{ zIndex: 1055 }}
>
<div className="modal-dialog modal-dialog-centered" style={{ maxWidth: '600px' }}>
<div className="modal-content">
{/* Search input */}
<div className="modal-header border-0 pb-0">
<div className="input-icon w-100">
<span className="input-icon-addon">
<IconSearch size={20} />
</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" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{filteredCommands.length === 0 ? (
<div className="text-center text-muted py-4">
Команды не найдены
</div>
) : (
<div className="list-group list-group-flush">
{filteredCommands.map((cmd, index) => {
const Icon = cmd.icon
return (
<button
key={index}
className={`list-group-item list-group-item-action d-flex align-items-center ${selectedIndex === index ? 'active' : ''}`}
onClick={() => executeCommand(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={`avatar avatar-sm me-3 ${selectedIndex === index ? 'bg-white text-primary' : 'bg-blue-lt text-blue'}`}>
<Icon size={20} />
</span>
<div className="flex-grow-1 text-start">
<div className="fw-bold">{cmd.label}</div>
<div className="text-muted small">{cmd.description}</div>
</div>
{selectedIndex === index && (
<kbd className="bg-white text-muted border"></kbd>
)}
</button>
)
})}
</div>
)}
</div>
{/* Footer with hints */}
<div className="modal-footer border-0 pt-0">
<div className="d-flex gap-3 text-muted small w-100 justify-content-center">
<div><kbd></kbd> <kbd></kbd> навигация</div>
<div><kbd></kbd> выбрать</div>
<div><kbd>Esc</kbd> закрыть</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
/**
* Кнопка для показа shortcuts
*/
export function KeyboardShortcutsButton({ className = '' }) {
const [showModal, setShowModal] = useState(false)
const shortcuts = [
{ keys: ['Ctrl', 'K'], description: 'Открыть поиск команд' },
{ keys: ['Ctrl', 'N'], description: 'Добавить новую запись' },
{ keys: ['Ctrl', 'S'], description: 'Сохранить изменения' },
{ keys: ['Ctrl', 'F'], description: 'Поиск по таблице' },
{ keys: ['Esc'], description: 'Закрыть модалку / Отменить' },
{ keys: ['↑', '↓'], description: 'Навигация по списку' },
{ keys: ['Enter'], description: 'Подтвердить / Выбрать' },
]
return (
<>
<button
className={`btn btn-ghost-secondary btn-icon ${className}`}
onClick={() => setShowModal(true)}
title="Горячие клавиши"
>
<IconKeyboard size={20} />
</button>
{showModal && (
<>
<div
className="modal-backdrop fade show"
onClick={() => setShowModal(false)}
></div>
<div className="modal fade show d-block" tabIndex="-1">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Горячие клавиши</h5>
<button
type="button"
className="btn-close"
onClick={() => setShowModal(false)}
></button>
</div>
<div className="modal-body">
<div className="list-group list-group-flush">
{shortcuts.map((shortcut, index) => (
<div key={index} className="list-group-item d-flex justify-content-between align-items-center">
<span>{shortcut.description}</span>
<div className="d-flex gap-1">
{shortcut.keys.map((key, i) => (
<kbd key={i} className="bg-secondary-lt">{key}</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</>
)}
</>
)
}
export default CommandPalette