Files
vps-tracker/apps/web/src/components/global-search.tsx
T
2026-08-25 15:16:17 +07:00

169 lines
5.7 KiB
TypeScript

import { useEffect, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
ServerIcon,
WalletIcon,
Building2Icon,
FolderKanbanIcon,
LayoutDashboardIcon,
SearchIcon,
ShieldAlertIcon,
GlobeIcon,
} from 'lucide-react'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@cfdm/ui/components/command'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { providerByIdMap } from '@/lib/billmanager'
import { TruncatedText } from '@/components/truncated-text'
interface GlobalSearchProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
const navigate = useNavigate()
const { data: snapshot } = useQuery({ ...snapshotQueryOptions(), enabled: open })
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const go = (to: string, search?: Record<string, string>) => {
onOpenChange(false)
void navigate({ to, search })
}
const vpsItems = useMemo(() => snapshot?.vps ?? [], [snapshot])
const accountItems = useMemo(() => snapshot?.providerAccounts ?? [], [snapshot])
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Поиск"
description="VPS, аккаунты, проекты и навигация"
className="sm:max-w-lg"
>
<Command className="**:data-[selected=true]:bg-muted **:data-selected:bg-transparent">
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
<CommandList className="max-h-96">
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Навигация">
<CommandItem onSelect={() => go('/dashboard')}>
<LayoutDashboardIcon />
<span>Дашборд</span>
</CommandItem>
<CommandItem onSelect={() => go('/vps')}>
<ServerIcon />
<span>Все VPS</span>
</CommandItem>
<CommandItem onSelect={() => go('/blocking')}>
<ShieldAlertIcon />
<span>Статус блокировок</span>
</CommandItem>
<CommandItem onSelect={() => go('/geo')}>
<GlobeIcon />
<span>GeoIP</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
{vpsItems.slice(0, 50).map((v) => (
<CommandItem
key={v.id}
value={`${v.ip} ${v.dns} ${v.project}`}
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
>
<ServerIcon />
<TruncatedText>{v.ip || v.dns || v.id}</TruncatedText>
{v.project ? (
<span className="text-muted-foreground text-xs">{v.project}</span>
) : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Аккаунты">
{accountItems.map((a) => (
<CommandItem
key={a.id}
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
onSelect={() => go('/accounts')}
>
<WalletIcon />
<TruncatedText>{a.name}</TruncatedText>
{providerById.get(a.providerId)?.name ? (
<span className="text-muted-foreground text-xs">
{providerById.get(a.providerId)?.name}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Проекты">
{projectItems.map((p) => {
const row = p as { id: string; name: string }
return (
<CommandItem
key={row.id}
value={row.name}
onSelect={() => go('/vps', { project: row.name })}
>
<FolderKanbanIcon />
<TruncatedText>{row.name}</TruncatedText>
</CommandItem>
)
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Хостеры">
{(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon />
<TruncatedText>{p.name}</TruncatedText>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
export function useGlobalSearchHotkey(onOpen: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
onOpen()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onOpen])
}
export function GlobalSearchTrigger({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hidden items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted md:flex"
>
<SearchIcon className="size-4" />
<span>Поиск</span>
<kbd className="pointer-events-none rounded border bg-background px-1.5 font-mono text-xs">Ctrl+K</kbd>
</button>
)
}