389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
|
import type { ServerStatus, ServerType } from "@/lib/data"
|
|
import { Flag } from "@/components/flag"
|
|
import { StatusDot } from "@/components/status-dot"
|
|
import { Badge } from "@/components/reui/badge"
|
|
import { IconTile } from "@/components/reui/icon-tile"
|
|
import {
|
|
Frame,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from "@/components/reui/frame"
|
|
import {
|
|
Item,
|
|
ItemActions,
|
|
ItemContent,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group"
|
|
import { cn } from "@/lib/utils"
|
|
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
|
|
|
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
|
export const ALL_SERVERS_ID = "all"
|
|
|
|
export interface ServerTileItem {
|
|
id: string
|
|
name: string
|
|
country?: string
|
|
status?: ServerStatus
|
|
type?: ServerType
|
|
count?: number
|
|
enabled?: boolean
|
|
selectable?: boolean
|
|
title?: string
|
|
site?: string
|
|
host?: string
|
|
meta?: string
|
|
}
|
|
|
|
function typeBadgeVariant(
|
|
type: ServerType,
|
|
): "focus-light" | "info-light" | "success-light" {
|
|
if (type === "jump-host") return "focus-light"
|
|
if (type === "home-router") return "success-light"
|
|
return "info-light"
|
|
}
|
|
|
|
function typeBadgeLabel(type: ServerType): string {
|
|
if (type === "jump-host") return "JH"
|
|
if (type === "home-router") return "HR"
|
|
return "EN"
|
|
}
|
|
|
|
function ServerTypeBadge({ type }: { type: ServerType }) {
|
|
return (
|
|
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
|
|
{typeBadgeLabel(type)}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
function hostnameOf(item: ServerTileItem): string {
|
|
return item.name || item.host || ""
|
|
}
|
|
|
|
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
|
if (!q) return true
|
|
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
return hay.includes(q)
|
|
}
|
|
|
|
function isSelectable(item: ServerTileItem): boolean {
|
|
return item.selectable !== false
|
|
}
|
|
|
|
function ServerTileRail({
|
|
items,
|
|
selectedId,
|
|
onSelect,
|
|
allCount = 0,
|
|
showHeader = true,
|
|
showAll = true,
|
|
showCount = true,
|
|
showType = true,
|
|
headerRight,
|
|
loading = false,
|
|
preserveSelection = false,
|
|
className,
|
|
}: {
|
|
items: ServerTileItem[]
|
|
selectedId: string
|
|
onSelect: (id: string) => void
|
|
allCount?: number
|
|
showHeader?: boolean
|
|
showAll?: boolean
|
|
showCount?: boolean
|
|
showType?: boolean
|
|
headerRight?: ReactNode
|
|
loading?: boolean
|
|
preserveSelection?: boolean
|
|
className?: string
|
|
}) {
|
|
const [query, setQuery] = useState("")
|
|
const q = query.trim().toLowerCase()
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
const filtered = useMemo(
|
|
() => items.filter((item) => matchesQuery(item, q)),
|
|
[items, q],
|
|
)
|
|
|
|
const uniqueSites = useMemo(() => {
|
|
const sites = new Set(
|
|
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
|
|
)
|
|
return sites.size
|
|
}, [filtered])
|
|
|
|
const groupBySite = uniqueSites >= 2 && !q
|
|
|
|
const groups = useMemo(() => {
|
|
if (!groupBySite) {
|
|
return [{ site: "", items: filtered }]
|
|
}
|
|
const bySite = new Map<string, ServerTileItem[]>()
|
|
for (const item of filtered) {
|
|
const site = item.site?.trim() || "—"
|
|
const list = bySite.get(site)
|
|
if (list) list.push(item)
|
|
else bySite.set(site, [item])
|
|
}
|
|
return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
|
|
}, [filtered, groupBySite])
|
|
|
|
const showAllTile = showAll && !q
|
|
|
|
const resolvedId = useMemo(() => {
|
|
if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
|
|
if (items.some((item) => item.id === selectedId)) return selectedId
|
|
if (preserveSelection) return ""
|
|
if (showAll) return ALL_SERVERS_ID
|
|
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
|
|
}, [items, selectedId, showAll, preserveSelection])
|
|
|
|
useEffect(() => {
|
|
if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
|
|
}, [onSelect, resolvedId, selectedId])
|
|
|
|
const selectableIds = useMemo(() => {
|
|
const ids: string[] = []
|
|
if (showAllTile) ids.push(ALL_SERVERS_ID)
|
|
for (const item of filtered) {
|
|
if (isSelectable(item)) ids.push(item.id)
|
|
}
|
|
return ids
|
|
}, [filtered, showAllTile])
|
|
|
|
function moveSelection(delta: number) {
|
|
if (selectableIds.length === 0) return
|
|
const idx = selectableIds.indexOf(resolvedId)
|
|
const nextIdx =
|
|
idx < 0
|
|
? delta > 0 ? 0 : selectableIds.length - 1
|
|
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
|
|
const nextId = selectableIds[nextIdx]
|
|
if (nextId) onSelect(nextId)
|
|
}
|
|
|
|
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
if (loading) return
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault()
|
|
moveSelection(1)
|
|
return
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault()
|
|
moveSelection(-1)
|
|
return
|
|
}
|
|
if (event.key === "Home") {
|
|
event.preventDefault()
|
|
if (selectableIds[0]) onSelect(selectableIds[0])
|
|
return
|
|
}
|
|
if (event.key === "End") {
|
|
event.preventDefault()
|
|
const last = selectableIds[selectableIds.length - 1]
|
|
if (last) onSelect(last)
|
|
}
|
|
}
|
|
|
|
const allItem: ServerTileItem = {
|
|
id: ALL_SERVERS_ID,
|
|
name: "Все серверы",
|
|
title: "Все серверы",
|
|
count: allCount,
|
|
meta: String(allCount),
|
|
}
|
|
|
|
return (
|
|
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
|
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
|
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
|
|
{showHeader ? (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Серверы
|
|
</FrameTitle>
|
|
<span className="flex items-center gap-1.5">
|
|
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
|
{headerRight}
|
|
</span>
|
|
</div>
|
|
) : headerRight || loading ? (
|
|
<div className="flex justify-end gap-1.5">
|
|
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
|
{headerRight}
|
|
</div>
|
|
) : null}
|
|
<InputGroup>
|
|
<InputGroupAddon>
|
|
<SearchIcon className="size-3.5" />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Поиск…"
|
|
aria-label="Поиск сервера"
|
|
disabled={loading}
|
|
/>
|
|
</InputGroup>
|
|
</FrameHeader>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div
|
|
ref={listRef}
|
|
className="flex flex-col gap-0.5 p-1.5"
|
|
role="listbox"
|
|
aria-label="Серверы"
|
|
aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined}
|
|
tabIndex={0}
|
|
onKeyDown={handleListKeyDown}
|
|
>
|
|
{showAllTile ? (
|
|
<ServerTileButton
|
|
item={allItem}
|
|
selected={resolvedId === ALL_SERVERS_ID}
|
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
|
showCount={showCount}
|
|
showType={false}
|
|
icon={
|
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
|
<LayersIcon className="text-muted-foreground" />
|
|
</IconTile>
|
|
}
|
|
/>
|
|
) : null}
|
|
{groups.map((group) => (
|
|
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
|
|
{groupBySite && group.site ? (
|
|
<p className="sticky top-0 z-[1] bg-background/95 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground backdrop-blur-sm">
|
|
{group.site}
|
|
</p>
|
|
) : null}
|
|
{group.items.map((item) => (
|
|
<ServerTileButton
|
|
key={item.id}
|
|
item={item}
|
|
selected={resolvedId === item.id}
|
|
onSelect={() => onSelect(item.id)}
|
|
showCount={showCount}
|
|
showType={showType}
|
|
showSite={!groupBySite}
|
|
icon={
|
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
|
{item.country ? (
|
|
<Flag code={item.country} size={16} />
|
|
) : (
|
|
<ServerIcon className="text-muted-foreground" />
|
|
)}
|
|
</IconTile>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
{filtered.length === 0 && !showAllTile ? (
|
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
|
{loading
|
|
? "Загрузка серверов…"
|
|
: items.length === 0
|
|
? "Нет доступных серверов"
|
|
: "Ничего не найдено"}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
function ServerTileButton({
|
|
item,
|
|
selected,
|
|
onSelect,
|
|
showCount,
|
|
showType,
|
|
showSite = true,
|
|
icon,
|
|
}: {
|
|
item: ServerTileItem
|
|
selected: boolean
|
|
onSelect: () => void
|
|
showCount: boolean
|
|
showType: boolean
|
|
showSite?: boolean
|
|
icon: ReactNode
|
|
}) {
|
|
const isAll = item.id === ALL_SERVERS_ID
|
|
const selectable = isSelectable(item)
|
|
const hostname = isAll ? "Все серверы" : hostnameOf(item)
|
|
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
|
|
const showStatus = Boolean(item.status && item.status !== "online")
|
|
|
|
return (
|
|
<Item
|
|
id={`server-tile-${item.id}`}
|
|
size="xs"
|
|
variant={selected ? "muted" : "default"}
|
|
render={
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selected}
|
|
title={item.title ?? hostname}
|
|
disabled={!selectable}
|
|
onClick={onSelect}
|
|
/>
|
|
}
|
|
className={cn(
|
|
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
|
selected && "ring-1 ring-border",
|
|
item.enabled === false && !selected && "opacity-40",
|
|
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
|
)}
|
|
>
|
|
<ItemMedia>{icon}</ItemMedia>
|
|
<ItemContent className="min-w-0">
|
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
|
{showStatus ? <StatusDot status={item.status!} /> : null}
|
|
<span className="min-w-0 truncate">{hostname}</span>
|
|
{!isAll && showSite && item.site ? (
|
|
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
|
|
{item.site}
|
|
</Badge>
|
|
) : null}
|
|
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
|
|
</ItemTitle>
|
|
</ItemContent>
|
|
{meta ? (
|
|
<ItemActions>
|
|
<Badge
|
|
variant={selected ? "secondary" : "outline"}
|
|
size="xs"
|
|
className="tabular-nums"
|
|
>
|
|
{meta}
|
|
</Badge>
|
|
</ItemActions>
|
|
) : null}
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|