Init commit
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
SearchIcon, LayoutDashboardIcon, ActivityIcon, MapIcon, HeartPulseIcon,
|
||||
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
||||
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
||||
TerminalIcon, BellIcon, SettingsIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── Command item definition ──────────────────────────────────────────────────
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
group: string
|
||||
url: string
|
||||
icon: React.ReactNode
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
const ALL_ITEMS: CommandItem[] = [
|
||||
// Обзор
|
||||
{ id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: <LayoutDashboardIcon />, keywords: ["главная","home","overview"] },
|
||||
{ id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: <ActivityIcon />, keywords: ["bandwidth","traffic","клиенты","интерфейсы"] },
|
||||
{ id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: <MapIcon />, keywords: ["topology","топология","map"] },
|
||||
{ id: "uptime", title: "Мониторинг / Uptime", group: "Обзор", url: "/uptime", icon: <HeartPulseIcon />, keywords: ["ping","uptime","мониторинг","проверка"] },
|
||||
// Данные
|
||||
{ id: "domains", title: "Домены", group: "Данные", url: "/domains", icon: <GlobeIcon />, keywords: ["domain","dns","сайты"] },
|
||||
{ id: "ip-ranges", title: "IP-диапазоны", group: "Данные", url: "/ip-ranges", icon: <NetworkIcon />, keywords: ["cidr","subnet","подсети","префиксы"] },
|
||||
{ id: "asns", title: "ASN", group: "Данные", url: "/asns", icon: <LayersIcon />, keywords: ["autonomous system","автономная система"] },
|
||||
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
|
||||
// Управление
|
||||
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
|
||||
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
|
||||
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
|
||||
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
|
||||
{ id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: <ShieldCheckIcon />, keywords: ["vpn","tunnel","туннель","wg"] },
|
||||
{ id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: <CableIcon />, keywords: ["gre","ipsec","tunnel","туннель"] },
|
||||
{ id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: <NetworkIcon />, keywords: ["overlay","vni","vtep","l2"] },
|
||||
{ id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: <BoxIcon />, keywords: ["docker","container","образ","image"] },
|
||||
{ id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: <BadgeCheckIcon />, keywords: ["ssl","tls","cert","pki","x509"] },
|
||||
{ id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: <HardDriveIcon />, keywords: ["backup","backup","резервная копия"] },
|
||||
// Инструменты
|
||||
{ id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: <RouteIcon />, keywords: ["routing","wan","failover","переключение"] },
|
||||
{ id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: <GitForkIcon />, keywords: ["ospf","igp","neighbors","lsa"] },
|
||||
{ id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: <GitMergeIcon />, keywords: ["bgp","ebgp","ibgp","сессии","peers"] },
|
||||
{ id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: <ScanLineIcon />, keywords: ["probe","ping","traceroute","диагностика"] },
|
||||
{ id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: <TerminalIcon />, keywords: ["terminal","cli","ssh","консоль"] },
|
||||
// Система
|
||||
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
|
||||
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
|
||||
]
|
||||
|
||||
// ─── Scoring / filtering ──────────────────────────────────────────────────────
|
||||
|
||||
function score(item: CommandItem, q: string): number {
|
||||
const t = item.title.toLowerCase()
|
||||
const kws = (item.keywords ?? []).join(" ").toLowerCase()
|
||||
if (t === q) return 100
|
||||
if (t.startsWith(q)) return 90
|
||||
if (t.includes(q)) return 70
|
||||
if (kws.includes(q)) return 50
|
||||
return 0
|
||||
}
|
||||
|
||||
function filterItems(q: string): CommandItem[] {
|
||||
if (!q.trim()) return ALL_ITEMS
|
||||
const lq = q.trim().toLowerCase()
|
||||
return ALL_ITEMS
|
||||
.map(item => ({ item, s: score(item, lq) }))
|
||||
.filter(x => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map(x => x.item)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState("")
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const results = useMemo(() => filterItems(query), [query])
|
||||
|
||||
// ── Keyboard shortcut to open ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setOpen(prev => {
|
||||
if (!prev) { setQuery(""); setActiveIdx(0) }
|
||||
return !prev
|
||||
})
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false)
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
// ── Focus input on open ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => inputRef.current?.focus(), 10)
|
||||
}, [open])
|
||||
|
||||
// ── Close on route change ─────────────────────────────────────────────────
|
||||
useEffect(() => { setOpen(false) }, [pathname])
|
||||
|
||||
// ── Arrow key + Enter navigation ──────────────────────────────────────────
|
||||
function onInputKey(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setActiveIdx(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const item = results[activeIdx]
|
||||
if (item) navigate(item)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(item: CommandItem) {
|
||||
router.push(item.url)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// ── Scroll active item into view ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null
|
||||
el?.scrollIntoView({ block: "nearest" })
|
||||
}, [activeIdx])
|
||||
|
||||
// ── Reset active idx when query changes ───────────────────────────────────
|
||||
useEffect(() => { setActiveIdx(0) }, [query])
|
||||
|
||||
// ── Grouped results for display ───────────────────────────────────────────
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, CommandItem[]>()
|
||||
for (const item of results) {
|
||||
if (!map.has(item.group)) map.set(item.group, [])
|
||||
map.get(item.group)!.push(item)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [results])
|
||||
|
||||
// ── Running index across groups ───────────────────────────────────────────
|
||||
let runningIdx = 0
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className="relative z-10 w-full max-w-[620px] mx-4 rounded-xl border border-white/[0.08] shadow-2xl overflow-hidden"
|
||||
style={{ background: "#0c1526" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input row */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-white/[0.07]">
|
||||
<SearchIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder="Поиск страниц и функций…"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-xs px-1.5 py-0.5 rounded border border-white/10 hover:border-white/20"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
<kbd className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 hidden sm:block">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="max-h-[420px] overflow-y-auto py-2">
|
||||
{results.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Ничего не найдено по “{query}”
|
||||
</div>
|
||||
) : grouped.map(([group, items]) => (
|
||||
<div key={group}>
|
||||
{/* Group header */}
|
||||
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
||||
{group}
|
||||
</div>
|
||||
|
||||
{items.map(item => {
|
||||
const idx = runningIdx++
|
||||
const isActive = idx === activeIdx
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-idx={idx}
|
||||
onClick={() => navigate(item)}
|
||||
onMouseEnter={() => setActiveIdx(idx)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors",
|
||||
isActive
|
||||
? "bg-white/[0.07] text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"size-7 flex items-center justify-center rounded-md shrink-0 transition-colors [&_svg]:size-3.5",
|
||||
isActive ? "bg-primary/20 text-primary" : "bg-white/[0.04] text-muted-foreground",
|
||||
)}>
|
||||
{item.icon}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">{item.title}</span>
|
||||
{item.subtitle && (
|
||||
<span className="text-xs text-muted-foreground ml-2">{item.subtitle}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isActive && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 shrink-0">
|
||||
↵
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/[0.07] px-4 py-2 flex items-center gap-4 text-[10px] text-muted-foreground/50">
|
||||
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↑↓</kbd> навигация</span>
|
||||
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↵</kbd> открыть</span>
|
||||
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Esc</kbd> закрыть</span>
|
||||
<span className="ml-auto">
|
||||
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Ctrl</kbd>
|
||||
{" + "}
|
||||
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">K</kbd>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user