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

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:
Denozordec
2026-09-06 17:58:05 +07:00
parent 6123660346
commit fe32c9313a
47 changed files with 5371 additions and 1531 deletions
@@ -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>
)
}
+3
View File
@@ -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}
+132
View File
@@ -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>
)
}
+236
View File
@@ -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
View File
@@ -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 }
+372
View File
@@ -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,
}
+300
View File
@@ -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,
}
+203
View File
@@ -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,
}