feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
@@ -23,8 +23,11 @@ import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
|
||||
import type { DragEndEvent } from "@dnd-kit/core"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CopyIcon,
|
||||
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
|
||||
rules: FirewallRule[]
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (rule: FirewallRule) => void
|
||||
onDelete?: (rule: FirewallRule) => void
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
showServer?: boolean
|
||||
}
|
||||
|
||||
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
||||
function FirewallRulesDataGrid({
|
||||
rules,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
showServer = false,
|
||||
}: FirewallRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
const reorderable = Boolean(onReorder)
|
||||
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
|
||||
|
||||
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
||||
() => [
|
||||
...(reorderable
|
||||
? [{
|
||||
id: "drag",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: () => <DataGridTableDndRowHandle />,
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
headerClassName: indexPad,
|
||||
cellClassName: indexPad,
|
||||
},
|
||||
},
|
||||
...(showServer
|
||||
? [{
|
||||
id: "server",
|
||||
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||
{row.original.serverName || row.original.serverId || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "chain",
|
||||
accessorKey: "chain",
|
||||
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete?.(r)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить правило
|
||||
</DropdownMenuItem>
|
||||
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onEdit, onToggle],
|
||||
[onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
enableSorting: !reorderable,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || !onReorder) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -304,7 +352,16 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{reorderable ? (
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridTableDndRows
|
||||
dataIds={indexedRules.map((r) => r.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DataGridContainer>
|
||||
) : undefined}
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ function DataPageToolbarFrame({
|
||||
}
|
||||
|
||||
interface DataPageToolbarProps<T extends string = string> {
|
||||
leading?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
@@ -48,6 +49,7 @@ interface DataPageToolbarProps<T extends string = string> {
|
||||
}
|
||||
|
||||
function DataPageToolbar<T extends string = string>({
|
||||
leading,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Поиск…",
|
||||
@@ -61,6 +63,7 @@ function DataPageToolbar<T extends string = string>({
|
||||
}: DataPageToolbarProps<T>) {
|
||||
return (
|
||||
<DataPageToolbarFrame className={className}>
|
||||
{leading}
|
||||
{segmented && (
|
||||
<SegmentedControl
|
||||
value={segmented.value}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
|
||||
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
|
||||
|
||||
/**
|
||||
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
|
||||
* @see https://reui.io/preview/base/chart-23
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
|
||||
const chartConfig = {
|
||||
rx: { label: "RX", color: "var(--chart-rx)" },
|
||||
tx: { label: "TX", color: "var(--chart-tx)" },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function ChartLegendItem({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: {
|
||||
dataKey: string
|
||||
color: string
|
||||
value: number
|
||||
}[]
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
|
||||
{payload.map((item) => (
|
||||
<div key={item.dataKey}>
|
||||
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
|
||||
{item.dataKey === "rx" ? "RX" : "TX"}:
|
||||
</div>
|
||||
<div className="text-sm font-semibold tabular-nums">
|
||||
{fmtRate(item.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toChartData(rx: number[], tx: number[], rangeMinutes: number) {
|
||||
const n = Math.max(rx.length, tx.length, 1)
|
||||
const denom = Math.max(n - 1, 1)
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
|
||||
return {
|
||||
time: formatRangeAgo(minutesAgo),
|
||||
rx: rx[i] ?? 0,
|
||||
tx: tx[i] ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function TrafficRxTxChart({
|
||||
rx,
|
||||
tx,
|
||||
range = "1h",
|
||||
}: {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
range?: string
|
||||
}) {
|
||||
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||
const data = toChartData(rx, tx, rangeMinutes)
|
||||
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
||||
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 8"
|
||||
vertical={false}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickMargin={10}
|
||||
interval={tickEvery - 1}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||
tickMargin={8}
|
||||
width={72}
|
||||
/>
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
dataKey="rx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-rx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="tx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-tx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<div className="mb-1 flex items-center justify-center gap-6">
|
||||
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
ALL_SERVERS_ID,
|
||||
ServerTileRail,
|
||||
hostnameOf,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
|
||||
|
||||
const STORAGE_KEY = "mm:server-rail-collapsed"
|
||||
const DESKTOP_MQ = "(min-width: 768px)"
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(value: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerRailContextValue {
|
||||
openRail: () => void
|
||||
expandRail: () => void
|
||||
collapsed: boolean
|
||||
selectedLabel: string
|
||||
}
|
||||
|
||||
const ServerRailContext = createContext<ServerRailContextValue | null>(null)
|
||||
|
||||
function useServerRail() {
|
||||
const ctx = useContext(ServerRailContext)
|
||||
if (!ctx) {
|
||||
throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function ServerRailMobileButton() {
|
||||
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={collapsed ? undefined : "md:hidden"}
|
||||
aria-label={collapsed ? "Показать список серверов" : "Серверы"}
|
||||
onClick={() => {
|
||||
if (window.matchMedia(DESKTOP_MQ).matches) {
|
||||
expandRail()
|
||||
return
|
||||
}
|
||||
openRail()
|
||||
}}
|
||||
>
|
||||
<ServerIcon data-icon="inline-start" />
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerRailLayout({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
showAll = true,
|
||||
allCount = 0,
|
||||
showCount = true,
|
||||
showType = true,
|
||||
headerRight,
|
||||
loading = false,
|
||||
extra,
|
||||
header,
|
||||
banner,
|
||||
contentClassName,
|
||||
children,
|
||||
}: {
|
||||
items: ServerTileItem[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
showAll?: boolean
|
||||
allCount?: number
|
||||
showCount?: boolean
|
||||
showType?: boolean
|
||||
headerRight?: ReactNode
|
||||
loading?: boolean
|
||||
extra?: ReactNode
|
||||
header?: ReactNode
|
||||
banner?: ReactNode
|
||||
contentClassName?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(readCollapsed())
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
writeCollapsed(collapsed)
|
||||
}, [collapsed, hydrated])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id)
|
||||
setRailOpen(false)
|
||||
},
|
||||
[onSelect],
|
||||
)
|
||||
|
||||
const expandRail = useCallback(() => {
|
||||
setCollapsed(false)
|
||||
}, [])
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
|
||||
const item = items.find((entry) => entry.id === selectedId)
|
||||
return item ? hostnameOf(item) : "Сервер"
|
||||
}, [items, selectedId, showAll])
|
||||
|
||||
const ctx = useMemo<ServerRailContextValue>(
|
||||
() => ({
|
||||
openRail: () => setRailOpen(true),
|
||||
expandRail,
|
||||
collapsed,
|
||||
selectedLabel,
|
||||
}),
|
||||
[collapsed, expandRail, selectedLabel],
|
||||
)
|
||||
|
||||
const railHeaderRight = (
|
||||
<>
|
||||
{headerRight}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="hidden md:inline-flex"
|
||||
aria-label="Свернуть список серверов"
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<PanelLeftCloseIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
showAll={showAll}
|
||||
allCount={allCount}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
headerRight={railHeaderRight}
|
||||
loading={loading}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerRailContext.Provider value={ctx}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{header}
|
||||
{banner}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{!collapsed ? (
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
{extra}
|
||||
</aside>
|
||||
) : null}
|
||||
<div className={cn("min-w-0 flex-1 overflow-y-auto p-4 md:p-6", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<ServerTileRail
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
showAll={showAll}
|
||||
allCount={allCount}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
showHeader={false}
|
||||
headerRight={headerRight}
|
||||
loading={loading}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{extra}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</ServerRailContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerRailLayout, ServerRailMobileButton }
|
||||
+224
-75
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react"
|
||||
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"
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
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,
|
||||
@@ -21,7 +29,7 @@ import {
|
||||
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 */
|
||||
/** 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 {
|
||||
@@ -34,6 +42,9 @@ export interface ServerTileItem {
|
||||
enabled?: boolean
|
||||
selectable?: boolean
|
||||
title?: string
|
||||
site?: string
|
||||
host?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
function typeBadgeVariant(
|
||||
@@ -58,12 +69,22 @@ function ServerTypeBadge({ type }: { type: ServerType }) {
|
||||
)
|
||||
}
|
||||
|
||||
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 ?? ""].join(" ").toLowerCase()
|
||||
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,
|
||||
@@ -74,6 +95,7 @@ function ServerTileRail({
|
||||
showCount = true,
|
||||
showType = true,
|
||||
headerRight,
|
||||
loading = false,
|
||||
className,
|
||||
}: {
|
||||
items: ServerTileItem[]
|
||||
@@ -85,18 +107,106 @@ function ServerTileRail({
|
||||
showCount?: boolean
|
||||
showType?: boolean
|
||||
headerRight?: ReactNode
|
||||
loading?: 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 (showAll) return ALL_SERVERS_ID
|
||||
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
|
||||
}, [items, selectedId, showAll])
|
||||
|
||||
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">
|
||||
@@ -106,10 +216,16 @@ function ServerTileRail({
|
||||
<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>
|
||||
) : headerRight ? (
|
||||
<div className="flex justify-end">{headerRight}</div>
|
||||
) : null}
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
@@ -120,18 +236,27 @@ function ServerTileRail({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
aria-label="Поиск сервера"
|
||||
disabled={loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
|
||||
<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
|
||||
selected={selectedId === ALL_SERVERS_ID}
|
||||
item={allItem}
|
||||
selected={resolvedId === ALL_SERVERS_ID}
|
||||
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||
title="Все серверы"
|
||||
name="Все"
|
||||
count={showCount ? allCount : undefined}
|
||||
showCount={showCount}
|
||||
showType={false}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
<LayersIcon className="text-muted-foreground" />
|
||||
@@ -139,32 +264,42 @@ function ServerTileRail({
|
||||
}
|
||||
/>
|
||||
) : 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>
|
||||
}
|
||||
/>
|
||||
{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">
|
||||
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
|
||||
{loading
|
||||
? "Загрузка серверов…"
|
||||
: items.length === 0
|
||||
? "Нет доступных серверов"
|
||||
: "Ничего не найдено"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -175,62 +310,76 @@ function ServerTileRail({
|
||||
}
|
||||
|
||||
function ServerTileButton({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
name,
|
||||
count,
|
||||
enabled = true,
|
||||
selectable = true,
|
||||
status,
|
||||
type,
|
||||
showCount,
|
||||
showType,
|
||||
showSite = true,
|
||||
icon,
|
||||
}: {
|
||||
item: ServerTileItem
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
title: string
|
||||
name: string
|
||||
count?: number
|
||||
enabled?: boolean
|
||||
selectable?: boolean
|
||||
status?: ServerStatus
|
||||
type?: ServerType
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={title}
|
||||
disabled={!selectable}
|
||||
onClick={onSelect}
|
||||
<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(
|
||||
"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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
<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}
|
||||
</button>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerTileRail, ServerTypeBadge }
|
||||
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
Reference in New Issue
Block a user