Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
237 lines
6.8 KiB
TypeScript
237 lines
6.8 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState, 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 { ScrollArea } from "@/components/ui/scroll-area"
|
|
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/preview/base/components/c-input-group-1 */
|
|
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
|
|
}
|
|
|
|
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 matchesQuery(item: ServerTileItem, q: string): boolean {
|
|
if (!q) return true
|
|
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
|
|
return hay.includes(q)
|
|
}
|
|
|
|
function ServerTileRail({
|
|
items,
|
|
selectedId,
|
|
onSelect,
|
|
allCount = 0,
|
|
showHeader = true,
|
|
showAll = true,
|
|
showCount = true,
|
|
showType = true,
|
|
headerRight,
|
|
className,
|
|
}: {
|
|
items: ServerTileItem[]
|
|
selectedId: string
|
|
onSelect: (id: string) => void
|
|
allCount?: number
|
|
showHeader?: boolean
|
|
showAll?: boolean
|
|
showCount?: boolean
|
|
showType?: boolean
|
|
headerRight?: ReactNode
|
|
className?: string
|
|
}) {
|
|
const [query, setQuery] = useState("")
|
|
const q = query.trim().toLowerCase()
|
|
|
|
const filtered = useMemo(
|
|
() => items.filter((item) => matchesQuery(item, q)),
|
|
[items, q],
|
|
)
|
|
|
|
const showAllTile = showAll && !q
|
|
|
|
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>
|
|
{headerRight}
|
|
</div>
|
|
) : headerRight ? (
|
|
<div className="flex justify-end">{headerRight}</div>
|
|
) : null}
|
|
<InputGroup>
|
|
<InputGroupAddon>
|
|
<SearchIcon className="size-3.5" />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Поиск…"
|
|
aria-label="Поиск сервера"
|
|
/>
|
|
</InputGroup>
|
|
</FrameHeader>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
|
|
{showAllTile ? (
|
|
<ServerTileButton
|
|
selected={selectedId === ALL_SERVERS_ID}
|
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
|
title="Все серверы"
|
|
name="Все"
|
|
count={showCount ? allCount : undefined}
|
|
icon={
|
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
|
<LayersIcon className="text-muted-foreground" />
|
|
</IconTile>
|
|
}
|
|
/>
|
|
) : null}
|
|
{filtered.map((item) => (
|
|
<ServerTileButton
|
|
key={item.id}
|
|
selected={selectedId === item.id}
|
|
onSelect={() => onSelect(item.id)}
|
|
title={item.title ?? item.name}
|
|
name={item.name}
|
|
count={showCount ? item.count : undefined}
|
|
enabled={item.enabled}
|
|
selectable={item.selectable}
|
|
status={item.status}
|
|
type={showType ? item.type : undefined}
|
|
icon={
|
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
|
{item.country ? (
|
|
<Flag code={item.country} size={16} />
|
|
) : (
|
|
<ServerIcon className="text-muted-foreground" />
|
|
)}
|
|
</IconTile>
|
|
}
|
|
/>
|
|
))}
|
|
{filtered.length === 0 && !showAllTile ? (
|
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
|
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
function ServerTileButton({
|
|
selected,
|
|
onSelect,
|
|
title,
|
|
name,
|
|
count,
|
|
enabled = true,
|
|
selectable = true,
|
|
status,
|
|
type,
|
|
icon,
|
|
}: {
|
|
selected: boolean
|
|
onSelect: () => void
|
|
title: string
|
|
name: string
|
|
count?: number
|
|
enabled?: boolean
|
|
selectable?: boolean
|
|
status?: ServerStatus
|
|
type?: ServerType
|
|
icon: ReactNode
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selected}
|
|
title={title}
|
|
disabled={!selectable}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
|
|
selected
|
|
? "bg-muted ring-1 ring-border"
|
|
: "hover:bg-muted/60",
|
|
!enabled && !selected && "opacity-40",
|
|
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
|
)}
|
|
>
|
|
{icon}
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
{status ? <StatusDot status={status} /> : null}
|
|
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
|
|
{type ? <ServerTypeBadge type={type} /> : null}
|
|
</span>
|
|
{count != null ? (
|
|
<Badge
|
|
variant={selected ? "secondary" : "outline"}
|
|
size="xs"
|
|
className="tabular-nums"
|
|
>
|
|
{count}
|
|
</Badge>
|
|
) : null}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export { ServerTileRail, ServerTypeBadge }
|