Update .env.example and README.md to clarify JWT_SECRET requirements; enhance telemt API route handling to preserve query parameters; adjust TypeScript configuration to exclude specific directories; improve data grid components with new features and optimizations.
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 3m58s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 19:22:26 +07:00
parent 9bebc9e92d
commit d35fa5a285
65 changed files with 6735 additions and 1027 deletions
+3
View File
@@ -5,8 +5,11 @@ REUI_LICENSE_KEY=
PANEL_MODE=standalone
TELEMT_API_URL=http://127.0.0.1:9091
TELEMT_AUTH_HEADER=
# REQUIRED in production (≥ 8 chars). Generate: openssl rand -hex 32
JWT_SECRET=dev-secret-change-me-please
JWT_TTL_HOURS=24
PANEL_ENCRYPTION_KEY=dev-encryption-key-change-me
PANEL_PUBLIC_URL=http://127.0.0.1:8080
BOOTSTRAP_USERNAME=admin
+6 -1
View File
@@ -6,17 +6,22 @@ Image: `git.shts.su/denozord/telemtpanel`
## Quick start (standalone)
**Обязательно** задайте `JWT_SECRET` (≥ 8 символов) — без него контейнер не запустится.
```bash
docker pull git.shts.su/denozord/telemtpanel:latest
docker run -d --name telemt-panel --network host \
-e PANEL_MODE=standalone \
-e TELEMT_API_URL=http://127.0.0.1:9091 \
-e JWT_SECRET=change-me-long \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e BOOTSTRAP_USERNAME=admin \
-e BOOTSTRAP_PASSWORD=change-me \
-v /var/lib/telemt-panel:/data \
git.shts.su/denozord/telemtpanel:latest
```
UI: `http://<IP-сервера>:8080` (логин `admin` / `BOOTSTRAP_PASSWORD`).
Полная инструкция (RU): **[docs/install.md](docs/install.md)**.
Также: [telemt-control-api.md](docs/telemt-control-api.md), [agent-protocol.md](docs/agent-protocol.md).
+6 -2
View File
@@ -9,7 +9,9 @@ import { sha256, randomBytes } from './auth.js'
export async function telemtRoutes(app: FastifyInstance) {
app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
const suffix = (request.params as { '*': string })['*']
const path = `/v1/${suffix}`
const qsIndex = request.url.indexOf('?')
const query = qsIndex >= 0 ? request.url.slice(qsIndex) : ''
const path = `/v1/${suffix}${query}`
const method = request.method.toUpperCase()
if (app.config.panelMode === 'standalone') {
@@ -96,7 +98,9 @@ export async function fleetRoutes(app: FastifyInstance) {
app.all('/api/servers/:id/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
const suffix = (request.params as { id: string; '*': string })['*']
const path = `/v1/${suffix}`
const qsIndex = request.url.indexOf('?')
const query = qsIndex >= 0 ? request.url.slice(qsIndex) : ''
const path = `/v1/${suffix}${query}`
const method = request.method.toUpperCase()
if (app.config.panelMode === 'standalone' || id === 'local') {
@@ -0,0 +1,691 @@
"use client"
"use no memo"
import { memo, useMemo, useState } from "react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridTableRowPin } from "@/components/reui/data-grid/data-grid-table"
import { Row, type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@telemt/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@telemt/ui/components/alert-dialog"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@telemt/ui/components/avatar"
import { Button } from "@telemt/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@telemt/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@telemt/ui/components/item"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@telemt/ui/components/progress"
import { Skeleton } from "@telemt/ui/components/skeleton"
import {
CATEGORY_LABELS,
ContactPriority,
ContactStatus,
IContact,
type CategoryLabel,
} from "./data"
import { MoreHorizontalIcon, PinOffIcon, Pin, EyeIcon, MailIcon, CopyIcon, Trash2Icon } from "lucide-react"
// ── Category tag colors (light + dark) ──
const categoryBadgeClass: Record<CategoryLabel, string> = {
"E-commerce":
"bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
Enterprise:
"bg-rose-100 text-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
P2P: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300",
AI: "bg-violet-100 text-violet-800 dark:bg-violet-950/50 dark:text-violet-300",
Digital: "bg-sky-100 text-sky-800 dark:bg-sky-950/50 dark:text-sky-300",
Infrastructure:
"bg-cyan-100 text-cyan-800 dark:bg-cyan-950/50 dark:text-cyan-300",
"Developer tools":
"bg-indigo-100 text-indigo-800 dark:bg-indigo-950/50 dark:text-indigo-300",
Automation:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-950/50 dark:text-yellow-300",
}
function getCategoryClasses(tag: string): string {
if (CATEGORY_LABELS.includes(tag as CategoryLabel)) {
return categoryBadgeClass[tag as CategoryLabel]
}
return "bg-muted text-muted-foreground"
}
export const CategoryTags = memo(function CategoryTags({
tags,
}: {
tags: CategoryLabel[]
}) {
return (
<div className="flex flex-wrap items-center gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className={cn("border-0", getCategoryClasses(tag))}
>
{tag}
</Badge>
))}
</div>
)
})
// ── Availability dot (aligned with data-grid-1 CustomerCell) ──
const availabilityColor: Record<string, string> = {
online: "bg-green-500",
away: "bg-yellow-400",
busy: "bg-red-500",
offline: "bg-gray-500",
}
// ── Status badge ──
const statusConfig: Record<ContactStatus, { dot: string }> = {
Active: { dot: "bg-emerald-500" },
Lead: { dot: "bg-blue-500" },
Prospect: { dot: "bg-amber-500" },
Churned: { dot: "bg-muted-foreground" },
}
export function StatusBadge({ status }: { status: ContactStatus }) {
return (
<Badge variant="outline">
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
statusConfig[status].dot
)}
/>
{status}
</Badge>
)
}
// ── Priority badge ──
const priorityConfig: Record<
ContactPriority,
{ variant: React.ComponentProps<typeof Badge>["variant"] }
> = {
High: { variant: "destructive-light" },
Medium: { variant: "warning-light" },
Low: { variant: "secondary" },
}
export function PriorityBadge({ priority }: { priority: ContactPriority }) {
return <Badge variant={priorityConfig[priority].variant}>{priority}</Badge>
}
// ── Score (shadcn Progress) ──
function ScoreCell({ score }: { score: number }) {
const indicatorClass =
score >= 75
? "**:data-[slot=progress-indicator]:bg-emerald-500"
: score >= 40
? "**:data-[slot=progress-indicator]:bg-amber-500"
: "**:data-[slot=progress-indicator]:bg-red-500"
return (
<Progress
value={score}
className={cn(
"min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-12 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2",
indicatorClass
)}
>
<ProgressLabel className="sr-only">Lead score</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{(_, value) => `${value ?? score}%`}
</ProgressValue>
</Progress>
)
}
// ── Stock sparkline (thin smooth curve; green = up, red = down vs series start) ──
// Smooth the polyline into a flowing cubic-bezier path: each segment's control
// points follow the slope of the neighbouring points (Catmull-Rom style).
function buildSmoothLinePath(pts: { x: number; y: number }[]): string {
if (pts.length === 0) return ""
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`
const smoothing = 0.2
const controlPoint = (
current: { x: number; y: number },
previous: { x: number; y: number } | undefined,
next: { x: number; y: number } | undefined,
reverse?: boolean
) => {
const p = previous ?? current
const n = next ?? current
const angle = Math.atan2(n.y - p.y, n.x - p.x) + (reverse ? Math.PI : 0)
const length = Math.hypot(n.x - p.x, n.y - p.y) * smoothing
return {
x: current.x + Math.cos(angle) * length,
y: current.y + Math.sin(angle) * length,
}
}
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length; i++) {
const start = controlPoint(pts[i - 1], pts[i - 2], pts[i])
const end = controlPoint(pts[i], pts[i - 1], pts[i + 1], true)
d += ` C ${start.x} ${start.y} ${end.x} ${end.y} ${pts[i].x} ${pts[i].y}`
}
return d
}
function StockSparkline({ data }: { data: number[] }) {
const w = 92
const h = 26
const padX = 2
const padY = 2
const innerW = w - padX * 2
const innerH = h - padY * 2
const { linePath, strokeClass } = useMemo(() => {
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const n = data.length
const step = innerW / Math.max(1, n - 1)
const pts = data.map((v, i) => {
const x = padX + i * step
const y = padY + ((max - v) / range) * innerH
return { x, y }
})
const linePath = buildSmoothLinePath(pts)
const delta = data[data.length - 1] - data[0]
const strokeClass =
delta > 0
? "stroke-emerald-600 dark:stroke-emerald-400"
: delta < 0
? "stroke-red-600 dark:stroke-red-400"
: "stroke-muted-foreground"
return { linePath, strokeClass }
}, [data])
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
className="shrink-0"
aria-hidden
>
<path
d={linePath}
fill="none"
className={cn(strokeClass)}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ── Contact cell (same layout as data-grid-1 CustomerCell) ──
const ContactCell = memo(function ContactCell({ row }: { row: Row<IContact> }) {
const o = row.original
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarImage src={o.avatar} alt="" />
<AvatarFallback>
{o.name
.split("")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[o.availability]
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">{o.name}</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={o.email}
>
{o.email}
</div>
</div>
</div>
)
})
// ── Actions cell ──
export function ActionsCell({ row }: { row: Row<IContact> }) {
const { copyToClipboard } = useCopyToClipboard()
const [deleteOpen, setDeleteOpen] = useState(false)
const isPinned = row.getIsPinned()
const handleDeleteConfirm = () => {
setDeleteOpen(false)
toast.message("Delete requested", {
description: `${row.original.name}. Connect your CRM (demo).`,
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Row actions"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => row.pin(isPinned ? false : "top")}>
{isPinned ? (
<PinOffIcon className="size-4" aria-hidden="true" />
) : (
<Pin className="size-4" aria-hidden="true" />
)}
{isPinned ? "Unpin" : "Pin"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.info("View contact", {
description: "Open your detail route (demo).",
})
}
>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message("Compose email", {
description: "Wire to your mailer (demo).",
})
}
>
<MailIcon className="size-4" aria-hidden="true" />
Email
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
copyToClipboard(row.original.email)
toast.success("Email copied", {
description: row.original.email,
})
}}
>
<CopyIcon className="size-4" aria-hidden="true" />
Copy Email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Delete contact?</AlertDialogTitle>
<AlertDialogDescription>
This will remove{" "}
<span className="text-foreground font-medium">
{row.original.name}
</span>
{" "}
from the list. Connect your API to persist changes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDeleteConfirm}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// ── Column definitions ──
export const columns: ColumnDef<IContact>[] = [
{
id: "pin",
header: "",
cell: ({ row }) => <DataGridTableRowPin row={row} />,
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
},
},
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader title="Contact" visibility={true} column={column} />
),
cell: ({ row }) => <ContactCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 200,
meta: {
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
accessorKey: "company",
id: "company",
header: ({ column }) => (
<DataGridColumnHeader title="Company" visibility={true} column={column} />
),
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2.5">
<Item className="w-auto shrink-0 border-0 p-0 [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
{row.original.companyLogo}
</ItemMedia>
</Item>
<span className="text-foreground min-w-0 truncate font-medium">
{row.original.company}
</span>
</div>
),
size: 140,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex min-w-0 items-center gap-2.5">
<Skeleton className="size-5 shrink-0 rounded" />
<Skeleton className="h-4 w-24" />
</div>
),
},
},
{
accessorKey: "jobTitle",
id: "jobTitle",
header: ({ column }) => (
<DataGridColumnHeader title="Title" visibility={true} column={column} />
),
cell: ({ row }) => (
<div className="min-w-0">
<div className="text-foreground truncate text-sm font-medium">
{row.original.jobTitle}
</div>
<div className="text-muted-foreground truncate text-xs">
{row.original.department}
</div>
</div>
),
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-24" />
</div>
),
},
},
{
accessorKey: "location",
id: "location",
header: ({ column }) => (
<DataGridColumnHeader
title="Location"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt=""
className="size-4 shrink-0 rounded-full object-cover"
/>
<span className="text-foreground truncate text-sm font-medium">
{row.original.location}
</span>
</div>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 shrink-0 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
),
},
},
{
accessorKey: "tags",
id: "tags",
header: ({ column }) => (
<DataGridColumnHeader
title="Category"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <CategoryTags tags={row.original.tags} />,
size: 220,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex flex-wrap items-center gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
),
},
},
{
accessorKey: "score",
id: "score",
header: ({ column }) => (
<DataGridColumnHeader title="Score" visibility={true} column={column} />
),
cell: ({ row }) => <ScoreCell score={row.original.score} />,
size: 148,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="h-2 min-w-12 flex-1 rounded-full" />
<Skeleton className="h-3 w-10 shrink-0" />
</div>
),
},
},
{
accessorKey: "engagementData",
id: "stock",
header: ({ column }) => (
<DataGridColumnHeader title="Stock" visibility={true} column={column} />
),
cell: ({ row }) => <StockSparkline data={row.original.engagementData} />,
size: 108,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-[26px] w-[92px] rounded-md" />,
},
},
{
accessorKey: "revenue",
id: "revenue",
header: ({ column }) => (
<DataGridColumnHeader title="Amount" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-foreground text-sm font-semibold tabular-nums">
$
{row.original.revenue.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
),
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
accessorKey: "lastContact",
id: "lastContact",
header: ({ column }) => (
<DataGridColumnHeader
title="Last Contact"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.lastContact}
</span>
),
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
accessorKey: "priority",
id: "priority",
header: ({ column }) => (
<DataGridColumnHeader
title="Priority"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <PriorityBadge priority={row.original.priority} />,
size: 90,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-6 w-16 rounded-full" />,
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader title="Status" visibility={true} column={column} />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
},
},
{
id: "actions",
header: "",
cell: ({ row }) => <ActionsCell row={row} />,
size: 60,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
},
},
]
@@ -0,0 +1,699 @@
"use no memo"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
RowPinningState,
SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
import {
ButtonGroup,
ButtonGroupText,
} from "@telemt/ui/components/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@telemt/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@telemt/ui/components/item"
import { Separator } from "@telemt/ui/components/separator"
import { TooltipProvider } from "@telemt/ui/components/tooltip"
import { CategoryTags, columns, PriorityBadge, StatusBadge } from "./columns"
import {
CATEGORY_LABELS,
CONTACTS,
type CategoryLabel,
type ContactPriority,
type ContactStage,
type ContactStatus,
type IContact,
} from "./data"
import { UserIcon, MailIcon, Building2Icon, MapPinIcon, CircleDotIcon, FlagIcon, GitBranchIcon, TagIcon, UserPlusIcon, FilterIcon, PinOffIcon, FunnelXIcon, MoreHorizontalIcon, FileDownIcon, SettingsIcon } from "lucide-react"
// ── Helpers ──
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
}))
)
}
function filterFieldValue(item: IContact, field: string): unknown {
if (field === "tags") return item.tags.join(" ")
return item[field as keyof IContact]
}
function applyFiltersToData(data: IContact[], filters: Filter[]): IContact[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
if (field === "tags") {
const selected = values.map(String)
switch (operator) {
case "is":
return (
selected.length > 0 &&
item.tags.includes(selected[0] as CategoryLabel)
)
case "is_not":
return !selected.some((v) => item.tags.includes(v as CategoryLabel))
case "is_any_of":
return selected.some((v) => item.tags.includes(v as CategoryLabel))
case "is_not_any_of":
return !selected.some((v) => item.tags.includes(v as CategoryLabel))
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
item.tags.some((t) =>
t.toLowerCase().includes(token.toLowerCase())
)
)
}
case "not_contains":
return !values.some((v) =>
item.tags.some((t) =>
t.toLowerCase().includes(String(v).toLowerCase())
)
)
default:
return true
}
}
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((v) => fieldValue === v)
case "is_not_any_of":
return !values.some((v) => fieldValue === v)
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
)
case "starts_with":
return values.some((v) =>
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
)
case "ends_with":
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
const STATUS_OPTIONS: { value: ContactStatus; label: string }[] = [
{ value: "Active", label: "Active" },
{ value: "Lead", label: "Lead" },
{ value: "Prospect", label: "Prospect" },
{ value: "Churned", label: "Churned" },
]
const PRIORITY_OPTIONS: { value: ContactPriority; label: string }[] = [
{ value: "High", label: "High" },
{ value: "Medium", label: "Medium" },
{ value: "Low", label: "Low" },
]
const STAGE_OPTIONS: { value: ContactStage; label: string }[] = [
{ value: "Awareness", label: "Awareness" },
{ value: "Consideration", label: "Consideration" },
{ value: "Decision", label: "Decision" },
{ value: "Retention", label: "Retention" },
]
const stageToneClass: Record<ContactStage, string> = {
Awareness: "bg-sky-500",
Consideration: "bg-amber-500",
Decision: "bg-emerald-500",
Retention: "bg-violet-500",
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function createDefaultContactFilters(): Filter[] {
return [createFilter("name", "contains", [""])]
}
// ── Main ──
export function ContactsGridView() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
jobTitle: false,
location: false,
priority: false,
lastContact: false,
})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: ["1", "2"],
bottom: [],
})
const [filters, setFilters] = useState<Filter[]>(createDefaultContactFilters)
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<IContact[]>(CONTACTS)
const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(getActiveFilters(createDefaultContactFilters()))
)
const resolvedRowPinning = useMemo(() => {
const availableIds = new Set(filteredData.map((contact) => contact.id))
return {
top: (rowPinning.top ?? []).filter((id) => availableIds.has(id)),
bottom: (rowPinning.bottom ?? []).filter((id) => availableIds.has(id)),
}
}, [filteredData, rowPinning.bottom, rowPinning.top])
const companyOptions = useMemo(() => {
return [
...new Map(
CONTACTS.map((contact) => [contact.company, contact])
).values(),
]
.sort((a, b) => a.company.localeCompare(b.company))
.map((contact) => ({
value: contact.company,
label: contact.company,
icon: (
<Item
render={<span />}
className="w-auto shrink-0 border-0 p-0 [&_svg]:size-4"
>
<ItemMedia variant="icon" className="size-auto">
{contact.companyLogo}
</ItemMedia>
</Item>
),
}))
}, [])
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: "name",
label: "Name",
icon: (
<UserIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-40",
placeholder: "Search...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-48",
placeholder: "Search...",
},
{
key: "company",
label: "Company",
icon: (
<Building2Icon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[180px]",
options: companyOptions,
customValueRenderer: (values, options) => {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
if (!option) return String(values[0])
return (
<div className="flex items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</div>
)
},
},
{
key: "location",
label: "Location",
icon: (
<MapPinIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-44",
placeholder: "City / region...",
},
{
key: "status",
label: "Status",
icon: (
<CircleDotIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[140px]",
options: STATUS_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <StatusBadge status={values[0] as ContactStatus} />
},
},
{
key: "priority",
label: "Priority",
icon: (
<FlagIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[120px]",
options: PRIORITY_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <PriorityBadge priority={values[0] as ContactPriority} />
},
},
{
key: "stage",
label: "Stage",
icon: (
<GitBranchIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[150px]",
options: STAGE_OPTIONS.map((stage) => ({
...stage,
icon: (
<span
className={cn(
"size-2 shrink-0 rounded-full",
stageToneClass[stage.value]
)}
aria-hidden="true"
/>
),
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
const stage = values[0] as ContactStage
return (
<Badge variant="outline">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
stageToneClass[stage]
)}
aria-hidden="true"
/>
{stage}
</Badge>
)
},
},
{
key: "tags",
label: "Category",
icon: (
<TagIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[180px]",
options: CATEGORY_LABELS.map((category) => ({
value: category,
label: category,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <CategoryTags tags={[values[0] as CategoryLabel]} />
},
},
],
[companyOptions]
)
const applyFilters = useCallback((newFilters: Filter[]) => {
return applyFiltersToData(CONTACTS, newFilters)
}, [])
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 400))
setFilteredData(applyFilters(newFilters))
setIsLoading(false)
},
[applyFilters]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
simulateAsyncFiltering(newFilters)
},
[simulateAsyncFiltering]
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
}
}, [filters, applyFilters])
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((c) => c.id as string)
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowPinning: resolvedRowPinning,
},
enableRowPinning: true,
keepPinnedRows: true,
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowPinningChange: setRowPinning,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const showClearButton = filters.length > 0
return (
<TooltipProvider delay={200}>
{/* Table */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
emptyMessage={
!isLoading && filteredData.length === 0
? "No contacts match your filters. Clear filters or adjust operators."
: undefined
}
tableLayout={{
rowsPinnable: true,
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Contacts
</FrameTitle>
<FrameDescription className="text-xs text-pretty">
{CONTACTS.length} CRM pipeline
</FrameDescription>
</div>
<Button
type="button"
size="default"
onClick={() =>
toast.info("Add contact", {
description: "Connect your CRM or signup flow. Demo only.",
})
}
>
<UserPlusIcon aria-hidden="true" />
Add contact
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
{/* customize: py-2.5 gives the filter row more breathing room than the tighter frame header token */}
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2.5">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Filters"
>
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
{(resolvedRowPinning.top?.length ?? 0) > 0 && (
<ButtonGroup>
<Button
type="button"
size="default"
variant="outline"
onClick={() => setRowPinning({ top: [], bottom: [] })}
>
<PinOffIcon aria-hidden />
Unpin all
</Button>
<ButtonGroupText className="text-muted-foreground px-3 text-sm">
{resolvedRowPinning.top?.length}{" "}
{resolvedRowPinning.top?.length === 1
? "contact"
: "contacts"}{" "}
pinned
</ButtonGroupText>
</ButtonGroup>
)}
{showClearButton && (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultContactFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next)
)
setFilters(next)
simulateAsyncFiltering(next)
}}
disabled={isLoading}
>
<FunnelXIcon aria-hidden />
Clear
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="default"
variant="outline"
aria-label="Table actions"
>
<MoreHorizontalIcon aria-hidden="true" />
Actions
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toast.success("Export ready", {
description: "Wire to your API. Demo only.",
})
}
>
<FileDownIcon aria-hidden="true" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message("Bulk email", {
description: "Connect your ESP. Demo only.",
})
}
>
<MailIcon aria-hidden="true" />
Bulk email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.info("Column settings", {
description:
"Use column headers to show or hide fields.",
})
}
>
<SettingsIcon aria-hidden="true" />
Column settings
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</TooltipProvider>
)
}
export { ContactsGridView as DataGridView }
@@ -0,0 +1,737 @@
import { type ReactNode } from "react"
import { AnthropicBlack } from "@telemt/ui/components/svgs/anthropicBlack"
import { AnthropicWhite } from "@telemt/ui/components/svgs/anthropicWhite"
import { Convex } from "@telemt/ui/components/svgs/convex"
import { CursorDark } from "@telemt/ui/components/svgs/cursorDark"
import { CursorLight } from "@telemt/ui/components/svgs/cursorLight"
import { Hono } from "@telemt/ui/components/svgs/hono"
import { Mintlify } from "@telemt/ui/components/svgs/mintlify"
import { ModelContextProtocolDark } from "@telemt/ui/components/svgs/modelContextProtocolDark"
import { ModelContextProtocolLight } from "@telemt/ui/components/svgs/modelContextProtocolLight"
import { N8n } from "@telemt/ui/components/svgs/n8n"
import { Neon } from "@telemt/ui/components/svgs/neon"
import { Openai } from "@telemt/ui/components/svgs/openai"
import { OpenaiDark } from "@telemt/ui/components/svgs/openaiDark"
import { Openclaw } from "@telemt/ui/components/svgs/openclaw"
import { Paper } from "@telemt/ui/components/svgs/paper"
import { Paypal } from "@telemt/ui/components/svgs/paypal"
import { Planetscale } from "@telemt/ui/components/svgs/planetscale"
import { PlanetscaleDark } from "@telemt/ui/components/svgs/planetscaleDark"
import { Prisma } from "@telemt/ui/components/svgs/prisma"
import { PrismaDark } from "@telemt/ui/components/svgs/prismaDark"
import { RemixDark } from "@telemt/ui/components/svgs/remixDark"
import { RemixLight } from "@telemt/ui/components/svgs/remixLight"
import { ResendIconBlack } from "@telemt/ui/components/svgs/resendIconBlack"
import { ResendIconWhite } from "@telemt/ui/components/svgs/resendIconWhite"
import { Slack } from "@telemt/ui/components/svgs/slack"
import { Stripe } from "@telemt/ui/components/svgs/stripe"
import { Supabase } from "@telemt/ui/components/svgs/supabase"
import { Surrealdb } from "@telemt/ui/components/svgs/surrealdb"
import { Zoom } from "@telemt/ui/components/svgs/zoom"
// ── Types ──
export type ContactStatus = "Lead" | "Active" | "Churned" | "Prospect"
export type ContactStage =
| "Awareness"
| "Consideration"
| "Decision"
| "Retention"
export type ContactPriority = "High" | "Medium" | "Low"
/** Closed vocabulary for category tags (filters + badge colors). */
export const CATEGORY_LABELS = [
"E-commerce",
"Enterprise",
"P2P",
"AI",
"Digital",
"Infrastructure",
"Developer tools",
"Automation",
] as const
export type CategoryLabel = (typeof CATEGORY_LABELS)[number]
export interface IContact {
id: string
name: string
avatar: string
availability: "online" | "away" | "busy" | "offline"
email: string
phone: string
company: string
companyLogo: ReactNode
jobTitle: string
department: string
location: string
flag: string
status: ContactStatus
stage: ContactStage
priority: ContactPriority
score: number
revenue: number
deals: number
lastContact: string
/** 1-4 labels from CATEGORY_LABELS */
tags: CategoryLabel[]
/** 16-point series for stock / trend sparkline */
engagementData: number[]
}
// ── Helpers ──
/** Deterministic wavy 16-point series per row id (1-10 realistic range). */
export function engagementSeriesForId(id: string): number[] {
const seed = id.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
const out: number[] = []
for (let i = 0; i < 16; i++) {
const wobble =
Math.sin((seed + i) * 0.85) * 2.8 + Math.cos((seed + i * 2) * 0.4) * 1.6
const base = 4.2 + (seed % 4) + ((i * 5 + seed) % 4) * 0.35
const v = Math.round(Math.max(1, Math.min(10, base + wobble)))
out.push(v)
}
return out
}
function VercelMark() {
return (
<svg
viewBox="0 0 76 65"
className="text-foreground size-5 shrink-0"
aria-hidden
>
<path fill="currentColor" d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
)
}
// ── Logos ──
const OPENAI_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Openai className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<OpenaiDark className="size-5" />
</span>
</>
)
const RESEND_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<ResendIconBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<ResendIconWhite className="size-5" />
</span>
</>
)
const ANTHROPIC_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<AnthropicBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<AnthropicWhite className="size-5" />
</span>
</>
)
const PRISMA_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Prisma className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PrismaDark className="size-5" />
</span>
</>
)
const REMIX_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<RemixLight className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<RemixDark className="size-5" />
</span>
</>
)
const MCP_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<ModelContextProtocolLight className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<ModelContextProtocolDark className="size-5" />
</span>
</>
)
const PLANETSCALE_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Planetscale className="text-foreground size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PlanetscaleDark className="size-5" />
</span>
</>
)
const CURSOR_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<CursorLight className="text-foreground size-5 [&_path]:fill-current" />
</span>
<span aria-hidden className="hidden dark:block">
<CursorDark className="text-foreground size-5 [&_path]:fill-current" />
</span>
</>
)
const SLACK_LOGO = <Slack className="size-5 shrink-0" aria-hidden="true" />
/** Light / dark symbol marks (see brand-logo-sourcing.mdc). */
const ZOOM_LOGO = <Zoom className="size-5 shrink-0" aria-hidden="true" />
// ── Data (22 rows, 1-4 categories each) ──
export const CONTACTS: IContact[] = [
{
id: "1",
name: "Theresa Webb",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 234-5678",
company: "Stripe",
companyLogo: <Stripe className="size-5" aria-hidden="true" />,
jobTitle: "VP of Sales",
department: "Sales",
location: "United States",
flag: "us",
status: "Active",
stage: "Retention",
priority: "High",
score: 92,
revenue: 48500,
deals: 7,
lastContact: "Mar 12, 2025",
tags: ["E-commerce", "Enterprise"],
engagementData: engagementSeriesForId("1"),
},
{
id: "2",
name: "Cameron Williamson",
avatar:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
availability: "away",
email: "[email protected]",
phone: "+1 (555) 345-6789",
company: "Mintlify",
companyLogo: <Mintlify className="size-5" aria-hidden="true" />,
jobTitle: "Head of Engineering",
department: "Engineering",
location: "United Kingdom",
flag: "gb",
status: "Active",
stage: "Decision",
priority: "High",
score: 93,
revenue: 62300,
deals: 5,
lastContact: "Jun 16, 2025",
tags: ["P2P", "AI", "Developer tools"],
engagementData: engagementSeriesForId("2"),
},
{
id: "3",
name: "Kathryn Murphy",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
availability: "busy",
email: "[email protected]",
phone: "+1 (555) 456-7890",
company: "Paper",
companyLogo: <Paper className="size-5" aria-hidden="true" />,
jobTitle: "Product Designer",
department: "Design",
location: "Germany",
flag: "de",
status: "Active",
stage: "Consideration",
priority: "Medium",
score: 42,
revenue: 31200,
deals: 3,
lastContact: "Aug 18, 2025",
tags: ["E-commerce", "P2P", "Digital"],
engagementData: engagementSeriesForId("3"),
},
{
id: "4",
name: "Dianne Russell",
avatar:
"https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 567-8901",
company: "OpenAI",
companyLogo: OPENAI_LOGO,
jobTitle: "Marketing Director",
department: "Marketing",
location: "Canada",
flag: "ca",
status: "Active",
stage: "Retention",
priority: "High",
score: 77,
revenue: 54700,
deals: 9,
lastContact: "Nov 29, 2025",
tags: ["Digital", "AI"],
engagementData: engagementSeriesForId("4"),
},
{
id: "5",
name: "Marvin McKinney",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
availability: "offline",
email: "[email protected]",
phone: "+1 (555) 678-9012",
company: "Resend",
companyLogo: RESEND_LOGO,
jobTitle: "Software Engineer",
department: "Engineering",
location: "France",
flag: "fr",
status: "Prospect",
stage: "Awareness",
priority: "Low",
score: 18,
revenue: 0,
deals: 1,
lastContact: "Mar 13, 2025",
tags: ["Digital", "P2P", "Automation"],
engagementData: engagementSeriesForId("5"),
},
{
id: "6",
name: "Devon Lane",
avatar:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 789-0123",
company: "Supabase",
companyLogo: <Supabase className="size-5" aria-hidden="true" />,
jobTitle: "Account Executive",
department: "Sales",
location: "Australia",
flag: "au",
status: "Active",
stage: "Decision",
priority: "High",
score: 22,
revenue: 78200,
deals: 12,
lastContact: "Feb 20, 2025",
tags: ["E-commerce", "AI", "Infrastructure"],
engagementData: engagementSeriesForId("6"),
},
{
id: "7",
name: "Wade Warren",
avatar:
"https://images.unsplash.com/photo-1542909168-82c3e7fdca5c?w=96&h=96&dpr=2&q=80",
availability: "away",
email: "[email protected]",
phone: "+1 (555) 890-1234",
company: "Anthropic",
companyLogo: ANTHROPIC_LOGO,
jobTitle: "Growth Manager",
department: "Growth",
location: "Japan",
flag: "jp",
status: "Lead",
stage: "Consideration",
priority: "Medium",
score: 11,
revenue: 14300,
deals: 2,
lastContact: "Apr 14, 2025",
tags: ["E-commerce", "AI"],
engagementData: engagementSeriesForId("7"),
},
{
id: "8",
name: "Courtney Henry",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
availability: "busy",
email: "[email protected]",
phone: "+1 (555) 901-2345",
company: "Prisma",
companyLogo: PRISMA_LOGO,
jobTitle: "HR Business Partner",
department: "People",
location: "Brazil",
flag: "br",
status: "Active",
stage: "Retention",
priority: "Medium",
score: 17,
revenue: 29800,
deals: 4,
lastContact: "Oct 1, 2025",
tags: ["E-commerce", "Digital", "P2P", "Enterprise"],
engagementData: engagementSeriesForId("8"),
},
{
id: "9",
name: "Jane Cooper",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 012-3456",
company: "Neon",
companyLogo: <Neon className="size-5" aria-hidden="true" />,
jobTitle: "Customer Success",
department: "CS",
location: "India",
flag: "in",
status: "Churned",
stage: "Awareness",
priority: "Low",
score: 65,
revenue: 8400,
deals: 1,
lastContact: "May 15, 2025",
tags: ["Digital", "Infrastructure"],
engagementData: engagementSeriesForId("9"),
},
{
id: "10",
name: "Floyd Miles",
avatar:
"https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?w=96&h=96&dpr=2&q=80",
availability: "offline",
email: "[email protected]",
phone: "+1 (555) 123-9999",
company: "N8n",
companyLogo: <N8n className="size-5" aria-hidden="true" />,
jobTitle: "CFO",
department: "Finance",
location: "Spain",
flag: "es",
status: "Active",
stage: "Decision",
priority: "High",
score: 88,
revenue: 91400,
deals: 14,
lastContact: "Apr 14, 2025",
tags: ["E-commerce", "Digital", "Automation"],
engagementData: engagementSeriesForId("10"),
},
{
id: "11",
name: "Leslie Alexander",
avatar:
"https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 222-3333",
company: "Convex",
companyLogo: <Convex className="size-5" aria-hidden="true" />,
jobTitle: "Scrum Master",
department: "Engineering",
location: "Italy",
flag: "it",
status: "Prospect",
stage: "Awareness",
priority: "Low",
score: 31,
revenue: 5200,
deals: 1,
lastContact: "Jan 8, 2025",
tags: ["P2P", "Developer tools"],
engagementData: engagementSeriesForId("11"),
},
{
id: "12",
name: "Robert Fox",
avatar:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
availability: "away",
email: "[email protected]",
phone: "+1 (555) 444-5555",
company: "Remix",
companyLogo: REMIX_LOGO,
jobTitle: "Enterprise AE",
department: "Sales",
location: "South Korea",
flag: "kr",
status: "Active",
stage: "Consideration",
priority: "Medium",
score: 59,
revenue: 33600,
deals: 6,
lastContact: "Feb 28, 2025",
tags: ["AI", "Digital", "Enterprise"],
engagementData: engagementSeriesForId("12"),
},
{
id: "13",
name: "Bessie Cooper",
avatar:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 666-7777",
company: "Model Context Protocol",
companyLogo: MCP_LOGO,
jobTitle: "DX Engineer",
department: "Developer Experience",
location: "Netherlands",
flag: "nl",
status: "Active",
stage: "Retention",
priority: "High",
score: 84,
revenue: 41200,
deals: 8,
lastContact: "Mar 5, 2025",
tags: ["AI", "E-commerce", "Developer tools"],
engagementData: engagementSeriesForId("13"),
},
{
id: "14",
name: "Jenny Wilson",
avatar:
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
availability: "busy",
email: "[email protected]",
phone: "+1 (555) 888-9999",
company: "Hono",
companyLogo: <Hono className="size-5" aria-hidden="true" />,
jobTitle: "Product Lead",
department: "Product",
location: "Sweden",
flag: "se",
status: "Lead",
stage: "Consideration",
priority: "Medium",
score: 46,
revenue: 18700,
deals: 3,
lastContact: "Nov 11, 2025",
tags: ["Digital", "P2P"],
engagementData: engagementSeriesForId("14"),
},
{
id: "15",
name: "Eleanor Pena",
avatar:
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 100-2001",
company: "Openclaw",
companyLogo: <Openclaw className="size-5 shrink-0" aria-hidden="true" />,
jobTitle: "Solutions Architect",
department: "Sales Engineering",
location: "Singapore",
flag: "sg",
status: "Active",
stage: "Decision",
priority: "High",
score: 71,
revenue: 55600,
deals: 9,
lastContact: "Jan 22, 2025",
tags: ["Infrastructure", "Developer tools", "Enterprise", "Digital"],
engagementData: engagementSeriesForId("15"),
},
{
id: "16",
name: "Jacob Jones",
avatar:
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
availability: "away",
email: "[email protected]",
phone: "+1 (555) 100-2002",
company: "PlanetScale",
companyLogo: PLANETSCALE_LOGO,
jobTitle: "Database Reliability",
department: "Infrastructure",
location: "Ireland",
flag: "ie",
status: "Active",
stage: "Retention",
priority: "Medium",
score: 68,
revenue: 42100,
deals: 5,
lastContact: "Dec 3, 2025",
tags: ["Infrastructure", "Developer tools"],
engagementData: engagementSeriesForId("16"),
},
{
id: "17",
name: "Kristin Watson",
avatar:
"https://images.unsplash.com/photo-1580489944761-15a19d654956?w=96&h=96&dpr=2&q=80",
availability: "busy",
email: "[email protected]",
phone: "+1 (555) 100-2003",
company: "Cursor",
companyLogo: CURSOR_LOGO,
jobTitle: "Design Ops",
department: "Design",
location: "Portugal",
flag: "pt",
status: "Prospect",
stage: "Awareness",
priority: "Low",
score: 24,
revenue: 2100,
deals: 1,
lastContact: "Feb 2, 2025",
tags: ["Digital", "Automation"],
engagementData: engagementSeriesForId("17"),
},
{
id: "18",
name: "Guy Hawkins",
avatar:
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
availability: "offline",
email: "[email protected]",
phone: "+1 (555) 100-2004",
company: "SurrealDB",
companyLogo: <Surrealdb className="size-5 shrink-0" aria-hidden="true" />,
jobTitle: "Platform Engineer",
department: "Engineering",
location: "Mexico",
flag: "mx",
status: "Lead",
stage: "Consideration",
priority: "Medium",
score: 38,
revenue: 12400,
deals: 2,
lastContact: "Mar 30, 2025",
tags: ["Infrastructure", "P2P", "AI"],
engagementData: engagementSeriesForId("18"),
},
{
id: "19",
name: "Annette Black",
avatar:
"https://images.unsplash.com/photo-1594744803329-e58b31de8bf5?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 100-2005",
company: "Slack",
companyLogo: SLACK_LOGO,
jobTitle: "Revenue Systems Lead",
department: "Revenue Ops",
location: "Norway",
flag: "no",
status: "Active",
stage: "Retention",
priority: "High",
score: 81,
revenue: 67800,
deals: 11,
lastContact: "Apr 8, 2025",
tags: ["Automation", "Digital", "Enterprise"],
engagementData: engagementSeriesForId("19"),
},
{
id: "20",
name: "Darrell Steward",
avatar:
"https://images.unsplash.com/photo-1463453091185-61582044d556?w=96&h=96&dpr=2&q=80",
availability: "away",
email: "[email protected]",
phone: "+1 (555) 100-2006",
company: "Vercel",
companyLogo: <VercelMark />,
jobTitle: "Developer Advocate",
department: "DX",
location: "United States",
flag: "us",
status: "Active",
stage: "Decision",
priority: "High",
score: 90,
revenue: 71200,
deals: 8,
lastContact: "May 1, 2025",
tags: ["Developer tools", "Digital", "AI"],
engagementData: engagementSeriesForId("20"),
},
{
id: "21",
name: "Brooklyn Simmons",
avatar:
"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=96&h=96&dpr=2&q=80",
availability: "online",
email: "[email protected]",
phone: "+1 (555) 100-2007",
company: "PayPal",
companyLogo: <Paypal className="size-5 shrink-0" aria-hidden="true" />,
jobTitle: "Partnerships Lead",
department: "Alliances",
location: "France",
flag: "fr",
status: "Churned",
stage: "Awareness",
priority: "Low",
score: 12,
revenue: 0,
deals: 0,
lastContact: "Jun 12, 2024",
tags: ["E-commerce"],
engagementData: engagementSeriesForId("21"),
},
{
id: "22",
name: "Cody Fisher",
avatar:
"https://images.unsplash.com/photo-1501196354995-cbb51c65aaea?w=96&h=96&dpr=2&q=80",
availability: "busy",
email: "[email protected]",
phone: "+1 (555) 100-2008",
company: "Zoom",
companyLogo: ZOOM_LOGO,
jobTitle: "Security Platform Lead",
department: "Security",
location: "Poland",
flag: "pl",
status: "Active",
stage: "Retention",
priority: "Medium",
score: 55,
revenue: 38900,
deals: 4,
lastContact: "Jul 19, 2025",
tags: ["Digital", "Enterprise", "Automation"],
engagementData: engagementSeriesForId("22"),
},
]
@@ -0,0 +1,15 @@
import { ContactsGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
CRM contacts data grid
</h1>
<ContactsGridView />
</main>
)
}
+109
View File
@@ -0,0 +1,109 @@
import type { ReactNode } from 'react'
import { cn } from '@telemt/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
interface MetricRowProps {
label: string
value: ReactNode
hint?: ReactNode
}
export function MetricRow({ label, value, hint }: MetricRowProps) {
return (
<div className="flex items-start justify-between gap-4 border-b border-border/60 py-3 last:border-b-0">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-sm">{label}</span>
{hint ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
</div>
<span className="text-right text-sm font-medium tabular-nums">{value}</span>
</div>
)
}
interface MetricListFrameProps {
title: string
description?: string
children: ReactNode
className?: string
trailing?: ReactNode
}
export function MetricListFrame({
title,
description,
children,
className,
trailing,
}: MetricListFrameProps) {
return (
<Frame className={cn('w-full', className)}>
<FrameHeader className="flex flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</div>
{trailing}
</FrameHeader>
<FramePanel className="flex flex-col">{children}</FramePanel>
</Frame>
)
}
interface StatusBadgeProps {
ok: boolean
okLabel?: string
failLabel?: string
}
export function StatusBadge({
ok,
okLabel = 'Онлайн',
failLabel = 'Недоступен',
}: StatusBadgeProps) {
return (
<Badge variant={ok ? 'success-light' : 'destructive-light'} size="sm">
{ok ? okLabel : failLabel}
</Badge>
)
}
interface RankedBarListProps {
items: Array<{ label: string; value: number }>
emptyLabel?: string
}
export function RankedBarList({ items, emptyLabel = 'Нет данных' }: RankedBarListProps) {
if (items.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyLabel}</p>
}
const max = Math.max(...items.map((i) => i.value), 1)
return (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div key={item.label} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate">{item.label}</span>
<span className="text-muted-foreground tabular-nums">
{new Intl.NumberFormat('ru-RU').format(item.value)}
</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }}
/>
</div>
</div>
))}
</div>
)
}
@@ -1,6 +1,9 @@
"use client"
"use no memo"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { type Column } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
@@ -29,7 +32,10 @@ function DataGridColumnFilter<TData, TValue>({
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
@@ -51,16 +57,13 @@ function DataGridColumnFilter<TData, TValue>({
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
@@ -70,7 +73,7 @@ function DataGridColumnFilter<TData, TValue>({
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
className="px-1 font-normal"
>
{option.label}
</Badge>
@@ -100,28 +103,39 @@ function DataGridColumnFilter<TData, TValue>({
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
@@ -130,12 +144,12 @@ function DataGridColumnFilter<TData, TValue>({
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
{facetCount}
</span>
)}
</div>
@@ -148,8 +162,16 @@ function DataGridColumnFilter<TData, TValue>({
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
@@ -1,11 +1,12 @@
"use client"
"use no memo"
import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react"
import { memo, useMemo } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { type Column } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
@@ -32,6 +33,7 @@ interface DataGridColumnHeaderProps<
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
@@ -45,11 +47,20 @@ function DataGridColumnHeaderInner<TData, TValue>({
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
// back to the definition order so Move Left/Right work out of the box.
const columnOrderState = table.getState().columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
@@ -76,18 +87,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
@@ -278,14 +289,14 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
@@ -301,7 +312,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
@@ -315,11 +326,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
@@ -1,6 +1,9 @@
import { type ReactElement } from "react"
"use client"
"use no memo"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { type Table } from "@tanstack/react-table"
import type { Table } from "@tanstack/react-table"
import {
DropdownMenu,
@@ -24,7 +27,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Колонки
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
@@ -1,6 +1,6 @@
"use client"
"use no memo"
import React, { type ReactNode } from "react"
import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@telemt/ui/lib/utils"
@@ -29,53 +29,44 @@ interface DataGridPaginationProps {
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
pageLabel?: string
previousPagesLabel?: string
nextPagesLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
pageLabel: "Page {page}",
previousPagesLabel: "Previous pages",
nextPagesLabel: "Next pages",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
const paginationInfo = mergedProps.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
@@ -94,8 +85,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
key={i}
size="icon-sm"
variant="ghost"
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
@@ -120,7 +109,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={mergedProps.previousPagesLabel}
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
@@ -138,7 +126,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
aria-label={mergedProps.nextPagesLabel}
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
@@ -153,12 +140,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0">
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
@@ -171,11 +158,15 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="min-w-18 tabular-nums" size="sm">
<SelectTrigger className="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
@@ -187,14 +178,14 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center gap-1 sm:order-2">
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
@@ -1,11 +1,8 @@
import {
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
"use client"
"use no memo"
import { useCallback, useEffect, useRef, useState } from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
@@ -23,6 +20,11 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
@@ -89,8 +91,9 @@ function DataGridScrollArea({
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
@@ -109,6 +112,11 @@ function DataGridScrollArea({
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getLeftTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getRightTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
@@ -118,12 +126,19 @@ function DataGridScrollArea({
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
// The overlay is mounted one commit after the sync that detected overflow,
// so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
if (node) applyMetrics(node, metricsRef.current)
}, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
@@ -191,8 +206,13 @@ function DataGridScrollArea({
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
}
setHasCustomVerticalOverflow((prev) =>
@@ -213,21 +233,6 @@ function DataGridScrollArea({
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
@@ -235,25 +240,69 @@ function DataGridScrollArea({
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
const observeElement = (element: HTMLElement | null) => {
if (element && observer && !observed.has(element)) {
observer.observe(element)
observed.add(element)
}
}
const resolveObservedElements = () => {
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
@@ -345,6 +394,10 @@ function DataGridScrollArea({
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
className={cn("relative", className)}
{...props}
>
@@ -363,11 +416,19 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -377,11 +438,11 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -389,6 +450,7 @@ function DataGridScrollArea({
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
ref={setOverlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
@@ -1,9 +1,9 @@
"use client"
"use no memo"
import {
createContext,
type CSSProperties,
type ReactNode,
memo,
useCallback,
useContext,
useEffect,
useId,
@@ -11,15 +11,19 @@ import {
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -31,23 +35,32 @@ import {
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
type CollisionDetection,
type DragCancelEvent,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { type Cell, flexRender, type HeaderGroup, type Row } from "@tanstack/react-table"
import { flexRender } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row, Table } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
@@ -60,23 +73,68 @@ const SortableRowContext = createContext<Pick<
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
/**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData> = (context: {
row: Row<TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel = "Reordering unavailable",
}: {
className?: string
/**
* Renders the grip inert instead of withdrawing it. A grid that reorders on
* one truth (manual order) and sorts on another cannot honour both at once,
* but dropping the handle entirely collapses the gutter and reads as broken
* rather than as unavailable. Keep the column's shape, mute the control.
*/
disabled?: boolean
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
if (!context || disabled) {
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
// The Button's own disabled treatment supplies the muting; only the
// cursor needs saying, so the grip reads as unavailable rather than
// merely unresponsive.
disabled && "cursor-not-allowed",
className
)}
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
title={disabled ? disabledLabel : undefined}
disabled
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
@@ -89,74 +147,252 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
function DataGridTableDndRow<TData>({
row,
renderRowDecoration,
}: {
row: Row<TData>
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
}) {
const rowData: DataGridTableDndRowData = {
type: "data-grid-row",
depth: row.depth,
index: row.index,
parentId: row.getParentRow()?.id ?? null,
}
const { transform, setNodeRef, isDragging, isOver, attributes, listeners } =
useSortable({
id: row.id,
data: rowData,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
// dnd-kit's transition is deliberately dropped. A transition on a transform
// property of a `tr` does not merely fail to animate in Chrome, it stops the
// transform applying at all: the element sits at the start value forever.
// The drag source escapes it because dnd-kit disables its own transition
// while it is being dragged, which is why the carried row used to be the
// ONLY one that moved and every other row silently refused to open a gap.
// Displacement therefore lands in one step, which is what a table wants.
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
// The row you are holding is drawn by the DragOverlay below, so the one
// left behind only has to show that it has been picked up, and it does that
// by fading. Nothing else: a border or a surface on it would compete with
// the clone that is actually being carried. It used to paint itself a solid
// background with inset hairlines, which was for the days when this row WAS
// the thing following the pointer.
...(isDragging && { opacity: 0.5 }),
}
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>, index, cells) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{decoration && index === cells.length - 1 ? (
// Rides inside the last cell rather than in a `td` of its own.
// An absolutely positioned `td` is still a cell as far as table
// layout is concerned, so it added a NINTH column with no width
// of its own, and under `table-layout: fixed` that new column
// swallowed the whole surplus the real columns had been sharing
// — every column snapped back to its declared size and the row's
// content visibly narrowed the moment a drag began. A plain
// element adds no column. It still anchors to the ROW, because
// the row is the nearest positioned ancestor, so the decoration
// spans the full width and is not clipped by the cell.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData>({
table,
dataIds,
renderRowDecoration,
sortingStrategy,
}: {
table: Table<TData>
dataIds: UniqueIdentifier[]
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
sortingStrategy: SortingStrategy
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={sortingStrategy}>
{table.getRowModel().rows.map((row: Row<TData>) => {
return (
<DataGridTableDndRow
row={row}
renderRowDecoration={renderRowDecoration}
key={row.id}
/>
)
})}
</SortableContext>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
collisionDetection = closestCenter,
modifiers,
sortingStrategy = verticalListSortingStrategy,
renderRowDecoration,
onDragStart,
onDragMove,
onDragOver,
onDragCancel,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
/** Overrides the default `closestCenter` strategy. */
collisionDetection?: CollisionDetection
/**
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
* to allow the horizontal gesture that tree re-parenting relies on. The
* table container clamp is always applied after these, so a dragged row
* cannot leave the grid.
*/
modifiers?: Modifier[]
/**
* Replaces the default `verticalListSortingStrategy`. Return null from a
* strategy to leave every row exactly where it is. A tree needs that: its drop
* is either INTO the hovered row or BETWEEN two rows, and which one it is
* flips as the pointer crosses a single row, so a gap that opens for one and
* shuts for the other flickers the whole surface. Such a caller draws its own
* insertion line instead, and pairs this with a modifier that holds the
* carried row still, since a gap nothing moves into is just a hole.
*/
sortingStrategy?: SortingStrategy
/** Per-row slot for drop indicators and depth guides. */
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
onDragStart?: (event: DragStartEvent) => void
onDragMove?: (event: DragMoveEvent) => void
onDragOver?: (event: DragOverEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const { table, props } = useDataGrid()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
const [carried, setCarried] = useState<{
id: UniqueIdentifier
width: number
columns: number[]
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
const head = tableContainerRef.current?.querySelector("thead tr")
if (!head) {
setCarried(null)
return
}
// The fill cell is a header-only spacer that soaks up the surplus a column
// resize leaves behind, and the clone renders data cells only. Measuring it
// in would make the clone's table wider than the cells it actually holds,
// and `table-fixed` hands that orphaned width back out across every column
// -- the carried row comes out visibly wider than the row it was lifted
// from. So the width is the sum of what we render, never the header's own.
const columns = Array.from(head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
columns,
})
}, [])
const carriedRow = carried
? table.getRowModel().rows.find((row: Row<TData>) => row.id === carried.id)
: undefined
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
@@ -175,7 +411,7 @@ function DataGridTableDndRows<TData>({
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
@@ -194,25 +430,48 @@ function DataGridTableDndRows<TData>({
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
// The horizontal rail only engages while the default axis restriction
// is in force. A row is exactly as wide as the viewport, so minX and
// maxX both collapse to 0 and clamping x erases it entirely: harmless
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
// caller that replaced the restriction precisely to READ x, as a tree
// does to resolve drop depth. Vertical is railed either way, which is
// what actually keeps a dragged row inside the grid.
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
// The container clamp is a safety rail rather than a policy, so it stays
// applied even when the caller replaces the axis restriction.
return [
...(modifiers ?? [restrictToVerticalAxis]),
restrictToTableContainer,
]
}, [modifiers])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
collisionDetection={collisionDetection}
modifiers={resolvedModifiers}
onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => {
setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors}
>
<DataGridTableViewport
@@ -229,7 +488,7 @@ function DataGridTableDndRows<TData>({
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
@@ -237,12 +496,12 @@ function DataGridTableDndRows<TData>({
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
<>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
</>
) : (
flexRender(
header.column.columnDef.header,
@@ -256,6 +515,7 @@ function DataGridTableDndRows<TData>({
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
@@ -266,35 +526,12 @@ function DataGridTableDndRows<TData>({
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
<MemoizedDataGridTableDndRowsBody
table={table}
dataIds={dataIds}
renderRowDecoration={renderRowDecoration}
sortingStrategy={sortingStrategy}
/>
</DataGridTableBody>
{footerContent && (
@@ -302,8 +539,54 @@ function DataGridTableDndRows<TData>({
)}
</DataGridTableBase>
</DataGridTableViewport>
{/* The row you are actually holding. It is a real clone rendered outside
the table, which is the only way a dragged row can follow the pointer
without disturbing the grid: it adds no cell, so it cannot alter the
column widths, and it floats above the rows rather than through them.
Its presence also tells dnd-kit to stop translating the source row, so
the row left behind simply dims in place. */}
<DragOverlay dropAnimation={null}>
{carried && carriedRow ? (
<table
aria-hidden="true"
style={{ width: carried.width, tableLayout: "fixed" }}
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
>
<tbody>
{/* Padding rides on the inner element, not the cell. A `td` can
never render narrower than its own horizontal padding, so a
column resized below that would silently widen here and the
clone would stop matching the row it came from. */}
<tr className="[&>td]:h-14 [&>td]:p-0 [&>td]:align-middle">
{carriedRow
.getVisibleCells()
.map((cell: Cell<TData, unknown>, index: number) => (
<td
key={cell.id}
// Falls back to the column's own size so an unforeseen
// header/cell count mismatch degrades to a real width
// rather than to `auto`.
style={{
width: carried.columns[index] ?? cell.column.getSize(),
}}
>
<div className="truncate px-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
))}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -1,12 +1,16 @@
"use client"
"use no memo"
import {
type CSSProperties,
Fragment,
type ReactNode,
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
@@ -17,6 +21,8 @@ import {
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -29,25 +35,27 @@ import {
closestCenter,
DndContext,
KeyboardSensor,
type Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
type Cell,
flexRender,
type Header,
type HeaderGroup,
type Row,
import { flexRender } from "@tanstack/react-table"
import type {
Cell,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { Button } from "@telemt/ui/components/button"
@@ -109,11 +117,11 @@ function DataGridTableDndHeader<TData>({
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
@@ -147,6 +155,68 @@ function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
)
}
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
@@ -154,15 +224,18 @@ function DataGridTableDnd<TData>({
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
@@ -182,33 +255,40 @@ function DataGridTableDnd<TData>({
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
@@ -231,7 +311,7 @@ function DataGridTableDnd<TData>({
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
@@ -243,6 +323,7 @@ function DataGridTableDnd<TData>({
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
@@ -253,51 +334,7 @@ function DataGridTableDnd<TData>({
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
@@ -1,18 +1,14 @@
"use client"
"use no memo"
import {
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -21,14 +17,19 @@ import {
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, type HeaderGroup, type Row, type Table } from "@tanstack/react-table"
import {
useVirtualizer,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
import { flexRender } from "@tanstack/react-table"
import type { Column, Row, Table } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import type {
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@telemt/ui/lib/utils"
@@ -44,6 +45,202 @@ type DataGridTableVirtualizerInstance = Virtualizer<
HTMLTableRowElement
>
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
containerElement: HTMLDivElement
headerSticky: boolean
isVirtualizationEnabled: boolean
rowId: string | undefined
rowIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableScrollRequest(
previous: DataGridTableVirtualScrollRequest | null,
next: DataGridTableVirtualScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.containerElement === next.containerElement &&
previous.headerSticky === next.headerSticky &&
previous.isVirtualizationEnabled === next.isVirtualizationEnabled &&
previous.rowId === next.rowId &&
previous.rowIndex === next.rowIndex &&
previous.scrollElement === next.scrollElement
)
}
function getDataGridTableScrollTarget({
align,
clientHeight,
rowBottom,
rowHeight,
rowTop,
scrollHeight,
scrollTop,
viewportTopOffset = 0,
}: {
align: DataGridTableVirtualScrollAlignment
clientHeight: number
rowBottom: number
rowHeight: number
rowTop: number
scrollHeight: number
scrollTop: number
viewportTopOffset?: number
}) {
const visibleHeight = Math.max(0, clientHeight - viewportTopOffset)
const viewportTop = scrollTop + viewportTopOffset
const viewportBottom = scrollTop + clientHeight
const targetTop =
align === "auto"
? rowTop < viewportTop
? rowTop - viewportTopOffset
: rowBottom > viewportBottom
? rowBottom - clientHeight
: null
: align === "start"
? rowTop - viewportTopOffset
: align === "end"
? rowBottom - clientHeight
: rowTop -
viewportTopOffset -
Math.max(0, (visibleHeight - rowHeight) / 2)
if (targetTop === null) return null
return Math.min(
Math.max(0, targetTop),
Math.max(0, scrollHeight - clientHeight)
)
}
function getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
}: {
containerElement: HTMLDivElement
headerSticky: boolean
scrollElement: HTMLElement
}) {
if (!headerSticky) return 0
const headerElement = containerElement.querySelector<HTMLElement>(
':scope > [data-slot="data-grid-table"] > thead'
)
if (!headerElement) return 0
const scrollRect = scrollElement.getBoundingClientRect()
const headerRect = headerElement.getBoundingClientRect()
const headerBottomOffset = headerRect.bottom - scrollRect.top
const overlapsViewportTop =
headerRect.top <= scrollRect.top + 0.5 && headerBottomOffset > 0
if (!overlapsViewportTop) return 0
return Math.min(scrollElement.clientHeight, Math.max(0, headerBottomOffset))
}
function scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
}: {
behavior: ScrollBehavior
scrollElement: HTMLElement
targetTop: number
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (virtualizer) {
virtualizer.scrollToOffset(targetTop, { align: "start", behavior })
} else if (typeof scrollElement.scrollTo === "function") {
scrollElement.scrollTo({ behavior, top: targetTop })
} else {
scrollElement.scrollTop = targetTop
}
}
function scrollDataGridTableRowIntoView({
align,
behavior,
cancelPendingScroll = false,
containerElement,
headerSticky,
rowIndex,
scrollElement,
virtualizer,
}: {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
cancelPendingScroll?: boolean
containerElement: HTMLDivElement | null
headerSticky: boolean
rowIndex: number
scrollElement: HTMLElement | null
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (!containerElement || !scrollElement) return false
const rowElement = containerElement.querySelector<HTMLTableRowElement>(
`:scope > [data-slot="data-grid-table"] > tbody > tr[data-index="${rowIndex}"]`
)
if (!rowElement) return false
const scrollRect = scrollElement.getBoundingClientRect()
const rowRect = rowElement.getBoundingClientRect()
const viewportTopOffset = getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
})
const rowTop = scrollElement.scrollTop + rowRect.top - scrollRect.top
const rowBottom = scrollElement.scrollTop + rowRect.bottom - scrollRect.top
const targetTop = getDataGridTableScrollTarget({
align,
clientHeight: scrollElement.clientHeight,
rowBottom,
rowHeight: rowRect.height || rowElement.offsetHeight,
rowTop,
scrollHeight: scrollElement.scrollHeight,
scrollTop: scrollElement.scrollTop,
viewportTopOffset,
})
if (
targetTop === null ||
Math.abs(targetTop - scrollElement.scrollTop) < 0.5
) {
if (cancelPendingScroll) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer,
})
}
return true
}
scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
})
return true
}
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
@@ -59,6 +256,12 @@ interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
/** Scroll animation used when revealing a controlled target row. */
scrollBehavior?: ScrollBehavior
/** Alignment used when revealing a controlled target row. Defaults to auto. */
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
@@ -70,7 +273,6 @@ interface DataGridTableVirtualProps<TData> {
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
@@ -85,49 +287,140 @@ interface VirtualBodyProps<TData> {
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
columnCount: number
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow({
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
columnCount,
}: {
table: Table<TData>
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table: _table,
columnCount,
table,
topRows,
centerRows,
bottomRows,
@@ -141,10 +434,25 @@ function DataGridTableVirtualBody<TData>({
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
void _table
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
@@ -181,7 +489,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
table={table}
height={leadingSpacerHeight}
/>
)
@@ -197,6 +505,7 @@ function DataGridTableVirtualBody<TData>({
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
@@ -205,23 +514,22 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
centerRows.forEach((row, rowIndex) => {
renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
@@ -234,7 +542,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
@@ -273,6 +581,9 @@ function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
footerContent,
renderHeader = true,
onFetchMore,
@@ -282,13 +593,12 @@ function DataGridTableVirtual<TData>({
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
@@ -314,10 +624,9 @@ function DataGridTableVirtual<TData>({
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
scrollElement: node
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
@@ -373,10 +682,135 @@ function DataGridTableVirtual<TData>({
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
const scrollToRowId =
scrollToRowIndex !== undefined
? centerRows[scrollToRowIndex]?.id
: undefined
const scrollToRowVirtualItem =
isVirtualizationEnabled && scrollToRowIndex !== undefined
? virtualItems.find((item) => item.index === scrollToRowIndex)
: undefined
const pendingScrollToRowIndexRef = useRef<number | null>(null)
const lastScrollRequestRef = useRef<DataGridTableVirtualScrollRequest | null>(
null
)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
useEffect(() => {
const previousRequest = lastScrollRequestRef.current
if (
scrollToRowIndex === undefined ||
scrollToRowIndex < 0 ||
scrollToRowIndex >= centerRows.length
) {
pendingScrollToRowIndexRef.current = null
lastScrollRequestRef.current = null
if (previousRequest) {
const scrollElement = resolveScrollElement()
if (scrollElement) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
}
}
return
}
const scrollElement = resolveScrollElement()
const containerElement = viewportElements.containerElement
if (!containerElement || !scrollElement) return
const headerSticky = renderHeader && !!props.tableLayout?.headerSticky
const nextRequest: DataGridTableVirtualScrollRequest = {
align: scrollToRowAlign,
behavior: scrollBehavior,
containerElement,
headerSticky,
isVirtualizationEnabled,
rowId: scrollToRowId,
rowIndex: scrollToRowIndex,
scrollElement,
}
if (isSameDataGridTableScrollRequest(previousRequest, nextRequest)) return
pendingScrollToRowIndexRef.current = null
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: scrollBehavior,
cancelPendingScroll: previousRequest !== null,
containerElement,
headerSticky,
rowIndex: scrollToRowIndex,
scrollElement,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
if (rowWasHandled) {
lastScrollRequestRef.current = nextRequest
return
}
if (!isVirtualizationEnabled) return
pendingScrollToRowIndexRef.current = scrollToRowIndex
lastScrollRequestRef.current = nextRequest
virtualizer.scrollToIndex(scrollToRowIndex, {
align: scrollToRowAlign,
behavior: scrollBehavior,
})
})
useEffect(() => {
if (
!isVirtualizationEnabled ||
scrollToRowIndex === undefined ||
pendingScrollToRowIndexRef.current !== scrollToRowIndex ||
!scrollToRowVirtualItem
) {
return
}
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: "auto",
cancelPendingScroll: true,
containerElement: viewportElements.containerElement,
headerSticky: renderHeader && !!props.tableLayout?.headerSticky,
rowIndex: scrollToRowIndex,
scrollElement: resolveScrollElement(),
virtualizer,
})
if (rowWasHandled) {
pendingScrollToRowIndexRef.current = null
}
}, [
isVirtualizationEnabled,
props.tableLayout?.headerSticky,
renderHeader,
resolveScrollElement,
scrollToRowAlign,
scrollToRowIndex,
scrollToRowVirtualItem,
virtualizer,
viewportElements.containerElement,
])
useEffect(() => {
if (
@@ -391,7 +825,10 @@ function DataGridTableVirtual<TData>({
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.()
}
}, [
@@ -412,35 +849,35 @@ function DataGridTableVirtual<TData>({
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
@@ -448,8 +885,36 @@ function DataGridTableVirtual<TData>({
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
@@ -461,7 +926,6 @@ function DataGridTableVirtual<TData>({
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
@@ -487,6 +951,7 @@ function DataGridTableVirtual<TData>({
export { DataGridTableVirtual }
export type {
DataGridTableVirtualScrollAlignment,
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,13 @@
"use client"
"use no memo"
import { createContext, type ReactNode, useContext, useMemo } from "react"
import {
type Column,
type ColumnFiltersState,
type RowData,
type SortingState,
type Table,
import { createContext, useContext, useMemo, useRef } from "react"
import type { ReactNode } from "react"
import type {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
@@ -19,6 +20,7 @@ declare module "@tanstack/react-table" {
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
}
}
@@ -55,6 +57,73 @@ export interface DataGridContextProps<TData extends object> {
table: Table<TData>
recordCount: number
isLoading: boolean
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
*/
autoSize?: DataGridAutoSizeController
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
*/
apply: (fillWidth: number) => boolean
}
function createDataGridAutoSizeController<TData extends object>(
table: Table<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
return {
apply(fillWidth: number) {
const columnSizing = table.getState().columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
return false
}
// Candidate switched (e.g. the grown column was hidden and another
// meta.autoSize column took over): revert the previous growth if the
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
next[revert.columnId] = revert.base
}
return next
})
return true
},
}
}
export type DataGridRequestParams = {
@@ -83,6 +152,7 @@ export interface DataGridProps<TData extends object> {
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
@@ -126,36 +196,56 @@ function DataGridProvider<TData extends object>({
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
// Re-assert an explicit tableLayout resize mode every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
// Without one, the consumer's own tanstack columnResizeMode (default
// "onEnd") is honored.
if (
props.tableLayout?.columnsResizable &&
props.tableLayout.columnsResizeMode
) {
table.options.columnResizeMode = props.tableLayout.columnsResizeMode
}
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice.
const autoSize = useMemo(
() => createDataGridAutoSizeController(table),
[table]
)
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
const value = useMemo(
() => ({
props,
get props() {
return propsRef.current
},
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
autoSize,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
@@ -165,6 +255,7 @@ function DataGridProvider<TData extends object>({
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.rowPinning,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
@@ -194,12 +285,14 @@ function DataGrid<TData extends object>({
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBackground: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
// columnsResizeMode has no default on purpose: when unset, the
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
@@ -210,7 +303,10 @@ function DataGrid<TData extends object>({
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
// z-40 keeps the sticky header above pinned body cells (zIndex 30 in
// getPinningStyles), which would otherwise paint over it while
// scrolling vertically with columnsPinnable enabled.
headerSticky: "sticky top-0 z-40 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
@@ -246,21 +342,16 @@ function DataGrid<TData extends object>({
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
/** Accepted for backwards compatibility; currently has no effect. */
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
className={cn("w-full overflow-hidden", className)}
>
{children}
</div>
+440 -192
View File
@@ -1,4 +1,4 @@
"use client"
"use no memo"
import type React from "react"
import {
@@ -47,7 +47,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@telemt/ui/components/tooltip"
import { AlertCircleIcon, XIcon, CheckIcon } from "lucide-react"
import { AlertCircleIcon, XIcon, CheckIcon, PlusIcon } from "lucide-react"
// i18n Configuration Interface
export interface FilterI18nConfig {
@@ -69,6 +69,9 @@ export interface FilterI18nConfig {
defaultCurrency: string
defaultColor: string
addFilterTitle: string
// Async option loading states (optional; fall back to sensible defaults)
loadingOptions?: string
errorLoadingOptions?: string
// Operators
operators: {
@@ -143,6 +146,8 @@ export const DEFAULT_I18N: FilterI18nConfig = {
defaultCurrency: "$",
defaultColor: "#000000",
addFilterTitle: "Add filter",
loadingOptions: "Loading...",
errorLoadingOptions: "Failed to load options.",
// Operators
operators: {
@@ -341,12 +346,18 @@ function FilterInput<T = unknown>({
<InputGroup
className={cn(
"w-36",
// Height follows each style's own control ladder. `default` sets no
// height on purpose so the style's `.cn-input-group` applies (h-8 nova,
// h-9 maia/luma, h-7 mira, h-10 sera); sm/lg step down/up from it.
// Base covers nova/lyra/rhea/vega; only deviating styles are listed.
context.size == "sm" &&
"h-7!",
context.size == "default" &&
"h-8!",
context.size == "lg" &&
"h-9!",
// Sera's `.cn-input` is `px-0` (underline inputs sit flush); inside a
// segmented chip that collides with the neighbouring segment, so give
// the value input the same inline padding sera uses elsewhere.
"",
className
)}
>
@@ -368,8 +379,6 @@ function FilterInput<T = unknown>({
className={cn(
context.size == "sm" &&
"h-7! text-xs",
context.size == "default" &&
"h-8!",
context.size == "lg" &&
"h-9!"
)}
@@ -421,6 +430,7 @@ function FilterRemoveButton({
? "icon-lg"
: "icon"
}
className={className}
{...props}
>
{icon}
@@ -451,6 +461,22 @@ export interface CustomRendererProps<T = unknown> {
operator: string
}
// Props passed to a field's `renderOptionList` slot. Lets a consumer render the
// options list however they like (e.g. windowing / virtualization with a
// library of their choice) while staying bound to the primitive's selection and
// keyboard behavior.
export interface FilterOptionListRenderProps<T = unknown> {
// Options to render: already resolved, query-filtered, and selected-first.
options: FilterOption<T>[]
// Index into `options` of the keyboard-highlighted row (-1 if none). A
// virtualized implementation should scroll this row into view and keep it
// mounted so the combobox's aria-activedescendant stays valid.
highlightedIndex: number
// Renders one option row with the correct id, selection state, highlight, and
// toggle handler wired to the primitive. Call it for each row you render.
renderOption: (option: FilterOption<T>, index: number) => React.ReactNode
}
// Grouped field configuration interface
export interface FilterFieldGroup<T = unknown> {
group?: string
@@ -472,6 +498,19 @@ export interface FilterFieldConfig<T = unknown> {
fields?: FilterFieldConfig<T>[]
// Field-specific options
options?: FilterOption<T>[]
// Async / large-list options loader. Receives the current search query and
// may return a Promise. Use it to prefetch a remote list once (ignore the
// query) or to run server-side search (filter by the query). When both
// `options` and `loadOptions` are provided, `options` seeds the initial view
// and the value->label cache while `loadOptions` supplies live results.
loadOptions?: (
query: string
) => FilterOption<T>[] | Promise<FilterOption<T>[]>
// Bring-your-own rendering for the options list (e.g. virtualization with a
// windowing library of your choice). Return the full scrollable list, call
// `renderOption` for each row, and scroll `highlightedIndex` into view. When
// omitted, the options render as a plain scrollable list.
renderOptionList?: (props: FilterOptionListRenderProps<T>) => React.ReactNode
operators?: FilterOperator[]
customRenderer?: (props: CustomRendererProps<T>) => React.ReactNode
customValueRenderer?: (
@@ -552,6 +591,138 @@ const getFieldsMap = <T = unknown,>(
)
}
// Whether a field exposes any option source (a static list or an async loader).
// IMPORTANT: never gate on `field.options?.length` once `loadOptions` exists —
// a function's `.length` is its arity, not an option count, which silently
// breaks the submenu gate for async fields.
const fieldHasOptions = <T = unknown,>(field: FilterFieldConfig<T>): boolean =>
(field.options?.length ?? 0) > 0 || typeof field.loadOptions === "function"
interface ResolvedFieldOptions<T = unknown> {
isAsync: boolean
options: FilterOption<T>[]
loading: boolean
error: boolean
// Resolve selected values to full options using an accumulating value->option
// cache, so async/controlled selections keep their label and icon even when
// absent from the latest result page.
resolveSelected: (values: T[]) => FilterOption<T>[]
}
// Value->option cache shared across every component instance rendering the
// SAME field object (the Add Filter submenu and the active-filter chip both
// receive the same config reference from the fields map). Keyed by the field
// object so it is shared when fields are memoized and garbage-collected
// otherwise. This keeps a value selected in the submenu labelled in the chip.
const fieldOptionCaches = new WeakMap<object, Map<unknown, FilterOption>>()
const getFieldOptionCache = <T = unknown,>(
field: FilterFieldConfig<T>
): Map<T, FilterOption<T>> => {
let cache = fieldOptionCaches.get(field as object)
if (!cache) {
cache = new Map()
fieldOptionCaches.set(field as object, cache)
}
return cache as Map<T, FilterOption<T>>
}
// Resolves a field's options for a popover/submenu. Static fields return their
// list verbatim (unchanged legacy behavior). Async fields (`loadOptions`)
// debounce the query, guard against out-of-order responses, and expose
// loading/error state plus a value->label cache.
function useFieldOptions<T = unknown>(
field: FilterFieldConfig<T>,
searchInput: string,
enabled: boolean
): ResolvedFieldOptions<T> {
const isAsync = typeof field.loadOptions === "function"
// Seed the shared cache from any static options an async field also provides
// (static fields never read this cache, so skip the work for them).
if (isAsync && field.options) {
const cache = getFieldOptionCache(field)
for (const opt of field.options) {
cache.set(opt.value, opt)
}
}
const [state, setState] = useState<{
options: FilterOption<T>[]
loading: boolean
error: boolean
}>(() => ({ options: field.options ?? [], loading: false, error: false }))
// Debounce the query for async fields to avoid a request per keystroke.
const [debouncedQuery, setDebouncedQuery] = useState(searchInput)
useEffect(() => {
if (!isAsync) return
const timer = setTimeout(() => setDebouncedQuery(searchInput), 250)
return () => clearTimeout(timer)
}, [searchInput, isAsync])
const requestIdRef = useRef(0)
// Keep the latest loader in a ref so an unmemoized `loadOptions` identity does
// not cancel and refire the in-flight request on every parent re-render.
const loaderRef = useRef(field.loadOptions)
loaderRef.current = field.loadOptions
useEffect(() => {
if (!isAsync || !enabled) return
const loader = loaderRef.current
if (!loader) return
const requestId = ++requestIdRef.current
let cancelled = false
setState((prev) => ({ ...prev, loading: true, error: false }))
Promise.resolve()
.then(() => loader(debouncedQuery))
.then((result) => {
// Ignore stale responses (out-of-order guard).
if (cancelled || requestId !== requestIdRef.current) return
const cache = getFieldOptionCache(field)
for (const opt of result) cache.set(opt.value, opt)
setState({ options: result, loading: false, error: false })
})
.catch(() => {
if (cancelled || requestId !== requestIdRef.current) return
setState((prev) => ({ ...prev, loading: false, error: true }))
})
return () => {
cancelled = true
}
}, [isAsync, enabled, debouncedQuery])
const resolveSelected = useCallback(
(values: T[]): FilterOption<T>[] => {
const cache = getFieldOptionCache(field)
return values.map(
(value) => cache.get(value) ?? { value, label: String(value) }
)
},
[field]
)
if (!isAsync) {
return {
isAsync: false,
options: field.options ?? [],
loading: false,
error: false,
resolveSelected,
}
}
return {
isAsync: true,
options: state.options,
loading: state.loading,
error: state.error,
resolveSelected,
}
}
// Helper function to create operators from i18n config
const createOperatorsFromI18n = (
i18n: FilterI18nConfig
@@ -582,7 +753,6 @@ const createOperatorsFromI18n = (
custom: [
{ value: "is", label: i18n.operators.is },
{ value: "after", label: i18n.operators.after },
{ value: "is", label: i18n.operators.is },
{ value: "between", label: i18n.operators.between },
{ value: "empty", label: i18n.operators.empty },
{ value: "not_empty", label: i18n.operators.notEmpty },
@@ -633,7 +803,10 @@ function FilterOperatorDropdown<T = unknown>({
onChange,
}: FilterOperatorDropdownProps<T>) {
const context = useFilterContext()
const operators = getOperatorsForField(field, values, context.i18n)
const operators = useMemo(
() => getOperatorsForField(field, values, context.i18n),
[field, values, context.i18n]
)
// Find the operator label, with fallback to formatted operator name
const operatorLabel =
@@ -723,20 +896,36 @@ function SelectOptionsPopover<T = unknown>({
}
}, [highlightedIndex, open, baseId])
const {
isAsync,
options: resolvedOptions,
loading,
error,
resolveSelected,
} = useFieldOptions(field, searchInput, inline || open)
const isMultiSelect = field.type === "multiselect" || values.length > 1
const effectiveValues =
(field.value !== undefined ? (field.value as T[]) : values) || []
const selectedOptions =
field.options?.filter((opt) => effectiveValues.includes(opt.value)) || []
const unselectedOptions =
field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || []
// Static fields read their list verbatim (unchanged legacy behavior). Async
// fields resolve selected values from the value->label cache and take the
// loader's (already query-filtered) result as the unselected list.
const selectedOptions = isAsync
? resolveSelected(effectiveValues)
: field.options?.filter((opt) => effectiveValues.includes(opt.value)) || []
const unselectedOptions = isAsync
? resolvedOptions.filter((opt) => !effectiveValues.includes(opt.value))
: field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || []
// Filter options based on search input
// Filter options based on search input (client-side for static lists; async
// loaders have already filtered by the query).
const filteredSelectedOptions = selectedOptions // Keep all selected visible
const filteredUnselectedOptions = unselectedOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchInput.toLowerCase())
)
const filteredUnselectedOptions = isAsync
? unselectedOptions
: unselectedOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchInput.toLowerCase())
)
const allFilteredOptions = useMemo(
() => [...filteredSelectedOptions, ...filteredUnselectedOptions],
@@ -748,6 +937,62 @@ function SelectOptionsPopover<T = unknown>({
onClose?.()
}
// Toggle a single option, shared by the plain and custom (renderOptionList)
// renderers so both behave identically.
const toggleOption = (option: FilterOption<T>) => {
const isSelected = effectiveValues.includes(option.value)
const next = isSelected
? (effectiveValues.filter((v) => v !== option.value) as T[])
: isMultiSelect
? ([...effectiveValues, option.value] as T[])
: ([option.value] as T[])
if (
!isSelected &&
isMultiSelect &&
field.maxSelections &&
next.length > field.maxSelections
) {
return
}
if (field.onValueChange) {
field.onValueChange(next)
} else {
onChange(next)
}
if (!isMultiSelect) handleClose()
}
const renderOptionItem = (option: FilterOption<T>, overallIndex: number) => {
const isSelected = effectiveValues.includes(option.value)
const isHighlighted = highlightedIndex === overallIndex
const itemId = `${baseId}-item-${overallIndex}`
return (
<DropdownMenuCheckboxItem
key={String(option.value)}
id={itemId}
role="option"
aria-selected={isHighlighted}
data-highlighted={isHighlighted || undefined}
onMouseEnter={() => setHighlightedIndex(overallIndex)}
checked={isSelected}
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
option.className
)}
onSelect={(e) => {
if (isMultiSelect) e.preventDefault()
}}
onCheckedChange={() => toggleOption(option)}
>
{option.icon && option.icon}
<span className="truncate">{option.label}</span>
</DropdownMenuCheckboxItem>
)
}
const renderMenuContent = () => (
<>
{field.searchable !== false && (
@@ -834,115 +1079,55 @@ function SelectOptionsPopover<T = unknown>({
role="listbox"
id={`${baseId}-listbox`}
>
<ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 [&_[data-slot=scroll-area-viewport]]:h-full [&_[data-slot=scroll-area-viewport]]:overscroll-contain">
{allFilteredOptions.length === 0 && (
<div className="text-muted-foreground py-2 text-center text-sm">
{context.i18n.noResultsFound}
</div>
)}
{/* Selected items */}
{filteredSelectedOptions.length > 0 && (
<DropdownMenuGroup className="px-1">
{filteredSelectedOptions.map((option, index) => {
const isHighlighted = highlightedIndex === index
const itemId = `${baseId}-item-${index}`
return (
<DropdownMenuCheckboxItem
key={String(option.value)}
id={itemId}
role="option"
aria-selected={isHighlighted}
data-highlighted={isHighlighted || undefined}
onMouseEnter={() => setHighlightedIndex(index)}
checked={true}
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
option.className
)}
onSelect={(e) => {
if (isMultiSelect) e.preventDefault()
}}
onCheckedChange={() => {
const next = effectiveValues.filter(
(v) => v !== option.value
) as T[]
if (field.onValueChange) {
field.onValueChange(next)
} else {
onChange(next)
}
if (!isMultiSelect) handleClose()
}}
>
{option.icon && option.icon}
<span className="truncate">{option.label}</span>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
)}
{/* Separator */}
{filteredSelectedOptions.length > 0 &&
filteredUnselectedOptions.length > 0 && (
<DropdownMenuSeparator className="mx-0" />
{isAsync && loading && allFilteredOptions.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{context.i18n.loadingOptions ?? DEFAULT_I18N.loadingOptions}
</div>
) : isAsync && error ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{context.i18n.errorLoadingOptions ??
DEFAULT_I18N.errorLoadingOptions}
</div>
) : allFilteredOptions.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{context.i18n.noResultsFound}
</div>
) : field.renderOptionList ? (
field.renderOptionList({
options: allFilteredOptions,
highlightedIndex,
renderOption: renderOptionItem,
})
) : (
<ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 [&_[data-slot=scroll-area-viewport]]:h-full [&_[data-slot=scroll-area-viewport]]:overscroll-contain">
{/* Selected items */}
{filteredSelectedOptions.length > 0 && (
<DropdownMenuGroup className="px-1">
{filteredSelectedOptions.map((option, index) =>
renderOptionItem(option, index)
)}
</DropdownMenuGroup>
)}
{/* Available items */}
{filteredUnselectedOptions.length > 0 && (
<DropdownMenuGroup className="px-1">
{filteredUnselectedOptions.map((option, index) => {
const overallIndex = index + filteredSelectedOptions.length
const isHighlighted = highlightedIndex === overallIndex
const itemId = `${baseId}-item-${overallIndex}`
{/* Separator */}
{filteredSelectedOptions.length > 0 &&
filteredUnselectedOptions.length > 0 && (
<DropdownMenuSeparator className="mx-0" />
)}
return (
<DropdownMenuCheckboxItem
key={String(option.value)}
id={itemId}
role="option"
aria-selected={isHighlighted}
data-highlighted={isHighlighted || undefined}
onMouseEnter={() => setHighlightedIndex(overallIndex)}
checked={false}
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
option.className
)}
onSelect={(e) => {
if (isMultiSelect) e.preventDefault()
}}
onCheckedChange={() => {
const next = isMultiSelect
? ([...effectiveValues, option.value] as T[])
: ([option.value] as T[])
if (
isMultiSelect &&
field.maxSelections &&
next.length > field.maxSelections
) {
return
}
if (field.onValueChange) {
field.onValueChange(next)
} else {
onChange(next)
}
if (!isMultiSelect) handleClose()
}}
>
{option.icon && option.icon}
<span className="truncate">{option.label}</span>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
)}
</ScrollArea>
{/* Available items */}
{filteredUnselectedOptions.length > 0 && (
<DropdownMenuGroup className="px-1">
{filteredUnselectedOptions.map((option, index) =>
renderOptionItem(
option,
index + filteredSelectedOptions.length
)
)}
</DropdownMenuGroup>
)}
</ScrollArea>
)}
</div>
</div>
</>
@@ -967,7 +1152,10 @@ function SelectOptionsPopover<T = unknown>({
<Button variant="outline" size={context.size}>
<div className="flex items-center gap-1.5">
{field.customValueRenderer ? (
field.customValueRenderer(values, field.options || [])
field.customValueRenderer(
values,
isAsync ? resolveSelected(values) : field.options || []
)
) : (
<>
{selectedOptions.length > 0 && (
@@ -1113,7 +1301,14 @@ export const FiltersContent = <T = unknown,>({
if (!field) return null
return (
<ButtonGroup key={filter.id}>
<ButtonGroup
key={filter.id}
// Sera is an underline style: its group text and input group carry
// only a bottom border. Normalise the boxed segments (the operator,
// value and remove buttons) to the same treatment so the whole chip
// reads as one underlined group instead of mixing boxes and rules.
className=""
>
<ButtonGroupText>
{field.icon && field.icon}
{field.label}
@@ -1189,6 +1384,14 @@ function FilterSubmenuContent<T = unknown>({
const inputRef = useRef<HTMLInputElement>(null)
const baseId = useId()
const {
isAsync,
options: resolvedOptions,
loading,
error,
resolveSelected,
} = useFieldOptions(field, searchInput, true)
useEffect(() => {
if (isActive) {
if (field.searchable !== false) {
@@ -1214,6 +1417,15 @@ function FilterSubmenuContent<T = unknown>({
}, [highlightedIndex, isActive, baseId])
const filteredOptions = useMemo(() => {
// Async fields: keep selected values first (resolved from cache so they
// stay labelled), then the loader's already-query-filtered results.
if (isAsync) {
const selectedSet = new Set(currentValues)
return [
...resolveSelected(currentValues),
...resolvedOptions.filter((option) => !selectedSet.has(option.value)),
]
}
return (
field.options?.filter((option) => {
const isSelected = currentValues.includes(option.value)
@@ -1222,7 +1434,43 @@ function FilterSubmenuContent<T = unknown>({
return option.label.toLowerCase().includes(searchInput.toLowerCase())
}) || []
)
}, [field.options, searchInput, currentValues])
}, [
isAsync,
resolvedOptions,
resolveSelected,
field.options,
searchInput,
currentValues,
])
const renderOptionItem = (option: FilterOption<T>, index: number) => {
const isSelected = currentValues.includes(option.value)
const isHighlighted = highlightedIndex === index
const itemId = `${baseId}-item-${index}`
return (
<DropdownMenuCheckboxItem
key={String(option.value)}
id={itemId}
role="option"
aria-selected={isHighlighted}
data-highlighted={isHighlighted || undefined}
onMouseEnter={() => setHighlightedIndex(index)}
checked={isSelected}
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
option.className
)}
onSelect={(e) => {
if (isMultiSelect) e.preventDefault()
}}
onCheckedChange={() => onToggle(option.value as T, isSelected)}
>
{option.icon && option.icon}
<span className="truncate">{option.label}</span>
</DropdownMenuCheckboxItem>
)
}
useEffect(() => {
if (isActive && filteredOptions.length > 0) {
@@ -1346,46 +1594,33 @@ function FilterSubmenuContent<T = unknown>({
}
}}
>
<ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 [&_[data-slot=scroll-area-viewport]]:h-full [&_[data-slot=scroll-area-viewport]]:overscroll-contain">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{i18n.noResultsFound}
</div>
) : (
{isAsync && loading && filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{i18n.loadingOptions ?? DEFAULT_I18N.loadingOptions}
</div>
) : isAsync && error ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{i18n.errorLoadingOptions ?? DEFAULT_I18N.errorLoadingOptions}
</div>
) : filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-2 text-center text-sm">
{i18n.noResultsFound}
</div>
) : field.renderOptionList ? (
field.renderOptionList({
options: filteredOptions,
highlightedIndex,
renderOption: renderOptionItem,
})
) : (
<ScrollArea className="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 [&_[data-slot=scroll-area-viewport]]:h-full [&_[data-slot=scroll-area-viewport]]:overscroll-contain">
<DropdownMenuGroup>
{filteredOptions.map((option, index) => {
const isSelected = currentValues.includes(option.value)
const isHighlighted = highlightedIndex === index
const itemId = `${baseId}-item-${index}`
return (
<DropdownMenuCheckboxItem
key={String(option.value)}
id={itemId}
role="option"
aria-selected={isHighlighted}
data-highlighted={isHighlighted || undefined}
onMouseEnter={() => setHighlightedIndex(index)}
checked={isSelected}
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
option.className
)}
onSelect={(e) => {
if (isMultiSelect) e.preventDefault()
}}
onCheckedChange={() =>
onToggle(option.value as T, isSelected)
}
>
{option.icon && option.icon}
<span className="truncate">{option.label}</span>
</DropdownMenuCheckboxItem>
)
})}
{filteredOptions.map((option, index) =>
renderOptionItem(option, index)
)}
</DropdownMenuGroup>
)}
</ScrollArea>
</ScrollArea>
)}
</div>
</div>
</div>
@@ -1481,13 +1716,16 @@ export function Filters<T = unknown>({
}
}, [lastAddedFilterId])
const mergedI18n: FilterI18nConfig = {
...DEFAULT_I18N,
...i18n,
operators: { ...DEFAULT_I18N.operators, ...i18n?.operators },
placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders },
validation: { ...DEFAULT_I18N.validation, ...i18n?.validation },
}
const mergedI18n: FilterI18nConfig = useMemo(
() => ({
...DEFAULT_I18N,
...i18n,
operators: { ...DEFAULT_I18N.operators, ...i18n?.operators },
placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders },
validation: { ...DEFAULT_I18N.validation, ...i18n?.validation },
}),
[i18n]
)
const fieldsMap = useMemo(() => getFieldsMap(fields), [fields])
@@ -1541,12 +1779,6 @@ export function Filters<T = unknown>({
[fieldsMap, filters, onChange]
)
useEffect(() => {
if (addFilterOpen && activeMenu === "root") {
rootInputRef.current?.focus()
}
}, [addFilterOpen, activeMenu])
const selectableFields = useMemo(() => {
const flatFields = flattenFields(fields)
return flatFields.filter((field) => {
@@ -1571,22 +1803,31 @@ export function Filters<T = unknown>({
}, [addFilterOpen, filteredFields.length])
const triggerButton = useRender({
render: trigger as React.ReactElement,
render: (trigger as React.ReactElement) ?? (
<Button variant="outline">
<PlusIcon
/>
{mergedI18n.addFilter}
</Button>
),
defaultTagName: "button",
})
const contextValue = useMemo<FilterContextValue>(
() => ({
variant,
size,
radius,
i18n: mergedI18n,
className,
trigger,
allowMultiple,
}),
[variant, size, radius, mergedI18n, className, trigger, allowMultiple]
)
return (
<FilterContext.Provider
value={{
variant,
size,
radius,
i18n: mergedI18n,
className,
trigger,
allowMultiple,
}}
>
<FilterContext.Provider value={contextValue}>
<div
className={cn(filtersContainerVariants({ variant, size }), className)}
>
@@ -1658,7 +1899,7 @@ export function Filters<T = unknown>({
field &&
(field.type === "select" ||
field.type === "multiselect") &&
field.options?.length
fieldHasOptions(field)
if (e.key === "ArrowRight" && hasSubMenu) {
e.preventDefault()
@@ -1678,7 +1919,7 @@ export function Filters<T = unknown>({
const hasSubMenu =
(field.type === "select" ||
field.type === "multiselect") &&
field.options?.length
fieldHasOptions(field)
if (!hasSubMenu) {
addFilter(field.key)
} else {
@@ -1730,7 +1971,7 @@ export function Filters<T = unknown>({
const hasSubMenu =
(field.type === "select" ||
field.type === "multiselect") &&
field.options?.length
fieldHasOptions(field)
if (hasSubMenu) {
const isMultiSelect = field.type === "multiselect"
@@ -1875,7 +2116,14 @@ export function Filters<T = unknown>({
const field = fieldsMap[filter.field]
if (!field) return null
return (
<ButtonGroup key={filter.id}>
<ButtonGroup
key={filter.id}
// Sera is an underline style: its group text and input group carry
// only a bottom border. Normalise the boxed segments (operator,
// value, remove) to the same treatment so the whole chip reads as
// one underlined group instead of mixing boxes and rules.
className=""
>
<ButtonGroupText className="bg-background dark:bg-input/30">
{field.icon && field.icon}
{field.label}
@@ -0,0 +1,15 @@
import type { SVGProps } from "react";
const AnthropicBlack = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
fillRule="evenodd"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
>
<title>Anthropic</title>
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
</svg>
);
export { AnthropicBlack };
@@ -0,0 +1,16 @@
import type { SVGProps } from "react";
const AnthropicWhite = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
fill="#ffff"
fillRule="evenodd"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
>
<title>Anthropic</title>
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
</svg>
);
export { AnthropicWhite };
@@ -0,0 +1,20 @@
import type { SVGProps } from "react";
const Convex = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="28 28 128 132" fill="none">
<path
fill="#F3B01C"
d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122Z"
/>
<path
fill="#8D2676"
d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555Z"
/>
<path
fill="#EE342F"
d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704Z"
/>
</svg>
);
export { Convex };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react"
const CursorDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path
fill="#fff"
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
/>
</svg>
)
export { CursorDark }
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const CursorLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
</svg>
);
export { CursorLight };
+17
View File
@@ -0,0 +1,17 @@
import type { SVGProps } from "react";
const Hono = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 330">
<path
d="M134.129.029c.876-.113 1.65.108 2.319.662a1256.253 1256.253 0 0 1 69.573 93.427c16.094 24.231 29.788 49.851 41.082 76.862 18.037 48.108 8.65 89.963-28.16 125.564-32.209 27.22-69.314 37.822-111.318 31.805-50.208-10.237-84.332-39.28-102.373-87.133C.553 225.638-.993 209.736.614 193.51c2.676-27.93 9.302-54.877 19.878-80.838 4.407-10.592 10.15-20.31 17.228-29.154a381.88 381.88 0 0 1 16.565 21.203c2.44 2.55 4.98 4.98 7.62 7.289C82.06 72.01 106.135 34.685 134.13.029Z"
fill="#FF5B11"
opacity=".993"
/>
<path
d="M129.49 53.7c24.314 28.2 46.29 58.238 65.93 90.114a187.318 187.318 0 0 1 15.24 33.13c8.338 32.804-.607 59.86-26.836 81.169-25.367 17.85-53.196 23.15-83.488 15.902-32.666-10.136-51.55-32.113-56.653-65.929-1.238-10.662-.133-21.043 3.314-31.142a225.41 225.41 0 0 1 17.89-35.78l19.878-29.155a5509.508 5509.508 0 0 0 44.726-58.31Z"
fill="#FF9758"
/>
</svg>
);
export { Hono };
@@ -0,0 +1,20 @@
import type { SVGProps } from "react";
const Mintlify = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 19 19" fill="none">
<path
d="M18.367 7.28888V1.59755C18.367 0.986819 17.8715 0.5 17.2699 0.5H11.5812C10.6877 0.5 9.80295 0.677018 8.98017 1.01336C8.15738 1.35856 7.40539 1.85424 6.77724 2.49152L6.733 2.53578C5.90137 3.37664 5.30862 4.42108 5.00781 5.57174C5.54749 5.43012 6.10483 5.35931 6.6622 5.35046C8.14852 5.33276 9.60831 5.81073 10.7938 6.7047C11.8643 7.50131 12.6783 8.59885 13.1206 9.86458C13.5807 11.148 13.6337 12.5465 13.2887 13.8653C14.43 13.5644 15.4828 12.9714 16.3233 12.1393L16.3675 12.0951C16.9957 11.4667 17.4999 10.7143 17.845 9.89114C18.19 9.06797 18.3581 8.18285 18.3581 7.28888H18.367Z"
fill="#18E299"
/>
<path
d="M4.83793 7.193C4.84674 5.44706 5.54303 3.77167 6.76814 2.51953L2.03511 7.25472C2.01749 7.27236 1.99985 7.28117 1.98222 7.29881C0.827615 8.44513 0.131342 9.97945 0.0167623 11.6019C-0.0890033 13.1186 0.307609 14.6176 1.15373 15.8698C1.23444 15.9892 1.45343 16.0285 1.57682 15.9139L4.47656 13.0216C5.38438 12.1134 5.66643 10.7642 5.23455 9.55618C4.96132 8.80666 4.82912 8.00424 4.83793 7.193Z"
fill="#0C8C5E"
/>
<path
d="M16.341 12.0938C15.4332 12.9844 14.2962 13.6016 13.0623 13.875C11.8195 14.1483 10.5327 14.0689 9.33405 13.6457C9.33405 13.6457 9.32522 13.6457 9.31641 13.6457C8.10892 13.2136 6.76042 13.4958 5.8526 14.3952L2.95282 17.2875C2.82943 17.4109 2.84706 17.6137 2.99689 17.7107C4.24845 18.5484 5.74683 18.954 7.26281 18.8482C8.88455 18.7336 10.4093 18.037 11.5639 16.8818L11.608 16.8378L16.341 12.1026V12.0938Z"
fill="#0C8C5E"
/>
</svg>
);
export { Mintlify };
@@ -0,0 +1,17 @@
import type { SVGProps } from "react";
const ModelContextProtocolDark = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
fill="#ffffff"
fillRule="evenodd"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
>
<title>ModelContextProtocol</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);
export { ModelContextProtocolDark };
@@ -0,0 +1,17 @@
import type { SVGProps } from "react";
const ModelContextProtocolLight = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
fill="#000000"
fillRule="evenodd"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
>
<title>ModelContextProtocol</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);
export { ModelContextProtocolLight };
+14
View File
@@ -0,0 +1,14 @@
import type { SVGProps } from "react";
const N8n = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 228 120">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M204 48C192.817 48 183.42 40.3514 180.756 30H153.248C147.382 30 142.376 34.241 141.412 40.0272L140.425 45.9456C139.489 51.5648 136.646 56.4554 132.626 60C136.646 63.5446 139.489 68.4352 140.425 74.0544L141.412 79.9728C142.376 85.759 147.382 90 153.248 90H156.756C159.42 79.6486 168.817 72 180 72C193.255 72 204 82.7452 204 96C204 109.255 193.255 120 180 120C168.817 120 159.42 112.351 156.756 102H153.248C141.516 102 131.504 93.5181 129.575 81.9456L128.588 76.0272C127.624 70.241 122.618 66 116.752 66H107.244C104.58 76.3514 95.183 84 84 84C72.817 84 63.4204 76.3514 60.7561 66H47.2439C44.5796 76.3514 35.183 84 24 84C10.7452 84 0 73.2548 0 60C0 46.7452 10.7452 36 24 36C35.183 36 44.5796 43.6486 47.2439 54H60.7561C63.4204 43.6486 72.817 36 84 36C95.183 36 104.58 43.6486 107.244 54H116.752C122.618 54 127.624 49.759 128.588 43.9728L129.575 38.0544C131.504 26.4819 141.516 18 153.248 18L180.756 18C183.42 7.64864 192.817 0 204 0C217.255 0 228 10.7452 228 24C228 37.2548 217.255 48 204 48ZM204 36C210.627 36 216 30.6274 216 24C216 17.3726 210.627 12 204 12C197.373 12 192 17.3726 192 24C192 30.6274 197.373 36 204 36ZM24 72C30.6274 72 36 66.6274 36 60C36 53.3726 30.6274 48 24 48C17.3726 48 12 53.3726 12 60C12 66.6274 17.3726 72 24 72ZM96 60C96 66.6274 90.6274 72 84 72C77.3726 72 72 66.6274 72 60C72 53.3726 77.3726 48 84 48C90.6274 48 96 53.3726 96 60ZM192 96C192 102.627 186.627 108 180 108C173.373 108 168 102.627 168 96C168 89.3726 173.373 84 180 84C186.627 84 192 89.3726 192 96Z"
fill="#ea4b71"
/>
</svg>
);
export { N8n };
+35
View File
@@ -0,0 +1,35 @@
import type { SVGProps } from "react";
const Neon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 256" preserveAspectRatio="xMidYMid">
<defs>
<linearGradient id="a" x1="100%" x2="12.069%" y1="100%" y2="0%">
<stop offset="0%" stopColor="#62F755" />
<stop offset="100%" stopColor="#8FF986" stopOpacity="0" />
</linearGradient>
<linearGradient id="b" x1="100%" x2="40.603%" y1="100%" y2="76.897%">
<stop offset="0%" stopOpacity=".9" />
<stop offset="100%" stopColor="#1A1A1A" stopOpacity="0" />
</linearGradient>
</defs>
<path
fill="#00E0D9"
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
/>
<path
fill="url(#a)"
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
/>
<path
fill="url(#b)"
fillOpacity=".4"
d="M0 44.139C0 19.762 19.762 0 44.139 0H211.86C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723h-76.36C19.763 256 0 236.238 0 211.861V44.14Zm44.139-8.825c-4.879 0-8.825 3.946-8.825 8.818v167.73c0 4.878 3.946 8.831 8.818 8.831h77.688c2.44 0 3.087-1.977 3.087-4.416v-101.22c0-25.222 31.914-36.166 47.395-16.255l48.391 62.243V44.14c0-4.879.455-8.825-4.416-8.825H44.14Z"
/>
<path
fill="#63F655"
d="M211.861 0C236.238 0 256 19.762 256 44.139v142.649c0 25.216-31.915 36.16-47.388 16.256l-48.392-62.251v75.484c0 21.939-17.784 39.723-39.722 39.723a4.409 4.409 0 0 0 4.409-4.409V115.058c0-25.223 31.914-36.167 47.395-16.256l48.391 62.243V8.825c0-4.871-3.953-8.825-8.832-8.825Z"
/>
</svg>
);
export { Neon };
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Openai = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260">
<path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
</svg>
);
export { Openai };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const OpenaiDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260">
<path
fill="#fff"
d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
/>
</svg>
);
export { OpenaiDark };
@@ -0,0 +1,42 @@
import type { SVGProps } from "react";
const Openclaw = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 120 120" fill="none">
<defs>
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#ff4d4d" />
<stop offset="100%" stopColor="#991b1b" />
</linearGradient>
</defs>
<path
d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z"
fill="url(#lobster-gradient)"
/>
<path
d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z"
fill="url(#lobster-gradient)"
/>
<path
d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z"
fill="url(#lobster-gradient)"
/>
<path
d="M45 15 Q35 5 30 8"
stroke="#ff4d4d"
strokeWidth="3"
strokeLinecap="round"
/>
<path
d="M75 15 Q85 5 90 8"
stroke="#ff4d4d"
strokeWidth="3"
strokeLinecap="round"
/>
<circle cx="45" cy="35" r="6" fill="#050810" />
<circle cx="75" cy="35" r="6" fill="#050810" />
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
</svg>
);
export { Openclaw };
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Paper = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 39 39" fill="none">
<path d="M39 24H24V6H6V24H24V39H0V6H6V0H39V24Z" fill="#81ADEC" />
</svg>
);
export { Paper };
@@ -0,0 +1,22 @@
import type { SVGProps } from "react";
const Paypal = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="7.056000232696533 3 37.35095977783203 45">
<g xmlns="http://www.w3.org/2000/svg" clipPath="url(#a)">
<path
fill="#002991"
d="M38.914 13.35c0 5.574-5.144 12.15-12.927 12.15H18.49l-.368 2.322L16.373 39H7.056l5.605-36h15.095c5.083 0 9.082 2.833 10.555 6.77a9.687 9.687 0 0 1 .603 3.58z"
/>
<path
fill="#60CDFF"
d="M44.284 23.7A12.894 12.894 0 0 1 31.53 34.5h-5.206L24.157 48H14.89l1.483-9 1.75-11.178.367-2.322h7.497c7.773 0 12.927-6.576 12.927-12.15 3.825 1.974 6.055 5.963 5.37 10.35z"
/>
<path
fill="#008CFF"
d="M38.914 13.35C37.31 12.511 35.365 12 33.248 12h-12.64L18.49 25.5h7.497c7.773 0 12.927-6.576 12.927-12.15z"
/>
</g>
</svg>
);
export { Paypal };
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Planetscale = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
<path d="M256 128a128 128 0 01-128 128zM128 0c52 0 96.7 31 116.8 75.5L75.5 244.8c-7.3-3.3-14.2-7.2-20.7-11.7L160 128h-32l-90.5 90.5A128 128 0 01128 0z" />
</svg>
);
export { Planetscale };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const PlanetscaleDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
<path
fill="#fff"
d="M256 128a128 128 0 01-128 128zM128 0c52 0 96.7 31 116.8 75.5L75.5 244.8c-7.3-3.3-14.2-7.2-20.7-11.7L160 128h-32l-90.5 90.5A128 128 0 01128 0z"
/>
</svg>
);
export { PlanetscaleDark };
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Prisma = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 310" preserveAspectRatio="xMidYMid">
<path d="M254.313 235.519L148 9.749A17.063 17.063 0 00133.473.037a16.87 16.87 0 00-15.533 8.052L2.633 194.848a17.465 17.465 0 00.193 18.747L59.2 300.896a18.13 18.13 0 0020.363 7.489l163.599-48.392a17.929 17.929 0 0011.26-9.722 17.542 17.542 0 00-.101-14.76l-.008.008zm-23.802 9.683l-138.823 41.05c-4.235 1.26-8.3-2.411-7.419-6.685l49.598-237.484c.927-4.443 7.063-5.147 9.003-1.035l91.814 194.973a6.63 6.63 0 01-4.18 9.18h.007z" />
</svg>
);
export { Prisma };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const PrismaDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 310" preserveAspectRatio="xMidYMid">
<path
fill="#fff"
d="M254.313 235.519L148 9.749A17.063 17.063 0 00133.473.037a16.87 16.87 0 00-15.533 8.052L2.633 194.848a17.465 17.465 0 00.193 18.747L59.2 300.896a18.13 18.13 0 0020.363 7.489l163.599-48.392a17.929 17.929 0 0011.26-9.722 17.542 17.542 0 00-.101-14.76l-.008.008zm-23.802 9.683l-138.823 41.05c-4.235 1.26-8.3-2.411-7.419-6.685l49.598-237.484c.927-4.443 7.063-5.147 9.003-1.035l91.814 194.973a6.63 6.63 0 01-4.18 9.18h.007z"
/>
</svg>
);
export { PrismaDark };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const RemixDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 297">
<path
d="M141.675 0C218.047 0 256 36.35 256 94.414c0 43.43-26.707 71.753-62.785 76.474 30.455 6.137 48.259 23.604 51.54 58.065l.474 6.337.415 5.924.358 5.542.249 4.179.267 4.93.138 2.814.198 4.47.159 4.222.079 2.427.107 3.888.092 4.446.033 2.148.06 6.226.02 6.496v3.885h-78.758l.004-1.62.028-3.147.047-3.065.136-7.424.035-2.489.027-3.902-.004-2.496-.023-2.617-.032-2.054-.064-2.876-.094-3.05-.125-3.242-.16-3.455-.096-1.813-.16-2.833-.186-2.976-.287-4.204-.247-3.342a116.56 116.56 0 0 0-.247-3.02l-.202-1.934c-2.6-22.827-11.655-32.157-27.163-35.269l-1.307-.245a60.184 60.184 0 0 0-2.704-.408l-1.397-.164c-.236-.025-.472-.05-.71-.073l-1.442-.127-1.471-.103-1.502-.081-1.514-.058-1.544-.039-1.574-.018L0 198.74V136.9h127.62c2.086 0 4.108-.04 6.066-.12l1.936-.095 1.893-.122 1.85-.15c.305-.028.608-.056.909-.086l1.785-.193a86.3 86.3 0 0 0 3.442-.475l1.657-.28c20.709-3.755 31.063-14.749 31.063-36.2 0-24.075-16.867-38.666-50.602-38.666H0V0h141.675ZM83.276 250.785c10.333 0 14.657 5.738 16.197 11.23l.203.79.167.782.109.617.046.306.078.603.058.59.023.29.031.569.01.278.008.54v29.507H0v-46.102h83.276Z"
fill="#ffff"
/>
</svg>
);
export { RemixDark };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const RemixLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 297">
<path
d="M141.675 0C218.047 0 256 36.35 256 94.414c0 43.43-26.707 71.753-62.785 76.474 30.455 6.137 48.259 23.604 51.54 58.065l.474 6.337.415 5.924.358 5.542.249 4.179.267 4.93.138 2.814.198 4.47.159 4.222.079 2.427.107 3.888.092 4.446.033 2.148.06 6.226.02 6.496v3.885h-78.758l.004-1.62.028-3.147.047-3.065.136-7.424.035-2.489.027-3.902-.004-2.496-.023-2.617-.032-2.054-.064-2.876-.094-3.05-.125-3.242-.16-3.455-.096-1.813-.16-2.833-.186-2.976-.287-4.204-.247-3.342a116.56 116.56 0 0 0-.247-3.02l-.202-1.934c-2.6-22.827-11.655-32.157-27.163-35.269l-1.307-.245a60.184 60.184 0 0 0-2.704-.408l-1.397-.164c-.236-.025-.472-.05-.71-.073l-1.442-.127-1.471-.103-1.502-.081-1.514-.058-1.544-.039-1.574-.018L0 198.74V136.9h127.62c2.086 0 4.108-.04 6.066-.12l1.936-.095 1.893-.122 1.85-.15c.305-.028.608-.056.909-.086l1.785-.193a86.3 86.3 0 0 0 3.442-.475l1.657-.28c20.709-3.755 31.063-14.749 31.063-36.2 0-24.075-16.867-38.666-50.602-38.666H0V0h141.675ZM83.276 250.785c10.333 0 14.657 5.738 16.197 11.23l.203.79.167.782.109.617.046.306.078.603.058.59.023.29.031.569.01.278.008.54v29.507H0v-46.102h83.276Z"
fill="#121212"
/>
</svg>
);
export { RemixLight };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const ResendIconBlack = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1800 1800" fill="none">
<path
d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z"
fill="black"
/>
</svg>
);
export { ResendIconBlack };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const ResendIconWhite = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1800 1800" fill="none">
<path
d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z"
fill="#FDFDFD"
/>
</svg>
);
export { ResendIconWhite };
+26
View File
@@ -0,0 +1,26 @@
import type { SVGProps } from "react";
const Slack = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 2447.6 2452.5">
<g clipRule="evenodd" fillRule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="#36c5f0"
/>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="#2eb67d"
/>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="#ecb22e"
/>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="#e01e5a"
/>
</g>
</svg>
);
export { Slack };
@@ -0,0 +1,14 @@
import type { SVGProps } from "react";
const Stripe = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" viewBox="100 100 312 312">
<path
fill="#533afd"
fillRule="evenodd"
d="m120 392 272-57.683V120l-272 58.357z"
clipRule="evenodd"
/>
</svg>
);
export { Stripe };
@@ -0,0 +1,45 @@
import type { SVGProps } from "react";
const Supabase = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 109 113" fill="none">
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint0_linear)"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint1_linear)"
fillOpacity="0.2"
/>
<path
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
fill="#3ECF8E"
/>
<defs>
<linearGradient
id="paint0_linear"
x1="53.9738"
y1="54.974"
x2="94.1635"
y2="71.8295"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#249361" />
<stop offset="1" stopColor="#3ECF8E" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="36.1558"
y1="30.578"
x2="54.4844"
y2="65.0806"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
export { Supabase };
@@ -0,0 +1,24 @@
import type { SVGProps } from "react";
const Surrealdb = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 299" preserveAspectRatio="xMidYMid">
<defs>
<linearGradient
id="a"
x1="31.047%"
x2="68.957%"
y1="11.705%"
y2="88.303%"
>
<stop offset="0%" stopColor="#FF00A0" />
<stop offset="100%" stopColor="#9600FF" />
</linearGradient>
</defs>
<path
fill="url(#a)"
d="m128 78.568 71.101 39.375v-15.787L128 62.872c-10.575 5.852-61.684 34.103-71.101 39.284 8.747 4.846 100.602 55.589 156.434 86.43v15.726C205.745 208.518 128 251.46 128 251.46a76502.912 76502.912 0 0 1-85.333-47.147v-47.146L128 204.312l14.232-7.862-113.798-62.842v78.598L128 267.185c9.813-5.425 92.282-50.987 99.535-55.01v-31.42L85.333 102.155 128 78.568ZM28.434 86.43v31.452l142.202 78.598-42.666 23.589-71.101-39.376v15.787l71.1 39.284c10.576-5.852 61.684-34.103 71.101-39.284-8.746-4.846-100.571-55.589-156.403-86.461V94.293C50.255 90.088 128 47.147 128 47.147a76501.239 76501.239 0 0 0 85.333 47.146v47.147L128 94.293l-14.232 7.863 113.767 62.873V86.43L128 31.421c-9.844 5.455-92.282 51.017-99.566 55.01ZM128 0 0 70.735v157.166l128 70.735 128-70.705V70.735L128 0Zm113.737 220.038L128 282.91 14.232 220.038V78.598L128 15.726l113.768 62.872-.03 141.44Z"
/>
</svg>
);
export { Surrealdb };
+39
View File
@@ -0,0 +1,39 @@
import { useId, type SVGProps } from "react"
const Zoom = (props: SVGProps<SVGSVGElement>) => {
const gradientId = useId()
return (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 256">
<defs>
<linearGradient
id={gradientId}
x1="23.666%"
x2="76.334%"
y1="95.6118%"
y2="4.3882%"
>
<stop offset=".00006%" stopColor="#0845BF" />
<stop offset="19.11%" stopColor="#0950DE" />
<stop offset="38.23%" stopColor="#0B59F6" />
<stop offset="50%" stopColor="#0B5CFF" />
<stop offset="67.32%" stopColor="#0E5EFE" />
<stop offset="77.74%" stopColor="#1665FC" />
<stop offset="86.33%" stopColor="#246FF9" />
<stop offset="93.88%" stopColor="#387FF4" />
<stop offset="100%" stopColor="#4F90EE" />
</linearGradient>
</defs>
<path
fill={`url(#${gradientId})`}
d="M256 128c0 13.568-1.024 27.136-3.328 40.192-6.912 43.264-41.216 77.568-84.48 84.48C155.136 254.976 141.568 256 128 256c-13.568 0-27.136-1.024-40.192-3.328-43.264-6.912-77.568-41.216-84.48-84.48C1.024 155.136 0 141.568 0 128c0-13.568 1.024-27.136 3.328-40.192 6.912-43.264 41.216-77.568 84.48-84.48C100.864 1.024 114.432 0 128 0c13.568 0 27.136 1.024 40.192 3.328 43.264 6.912 77.568 41.216 84.48 84.48C254.976 100.864 256 114.432 256 128Z"
/>
<path
fill="#FFF"
d="M204.032 207.872H75.008c-8.448 0-16.64-4.608-20.48-12.032-4.608-8.704-2.816-19.2 4.096-26.112l89.856-89.856H83.968c-17.664 0-32-14.336-32-32h118.784c8.448 0 16.64 4.608 20.48 12.032 4.608 8.704 2.816 19.2-4.096 26.112l-89.6 90.112h74.496c17.664 0 32 14.08 32 31.744Z"
/>
</svg>
)
}
export { Zoom }
@@ -0,0 +1,244 @@
import { useQuery } from '@tanstack/react-query'
import { MapPinIcon, NetworkIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { Separator } from '@telemt/ui/components/separator'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@telemt/ui/components/sheet'
import { ScrollArea } from '@telemt/ui/components/scroll-area'
import { Skeleton } from '@telemt/ui/components/skeleton'
import { api } from '@/lib/api-client'
import {
asRecord,
buildUserTimeline,
formatBytes,
formatEpoch,
formatNumber,
unwrapData,
type ApiEventRecord,
type TlsFingerprintRow,
type UserInfo,
} from '@/lib/telemt'
interface UserDetailSheetProps {
username: string | null
open: boolean
onOpenChange: (open: boolean) => void
}
/** User detail card — Frame DNA + ReUI Timeline. Docs: https://reui.io/docs/components/base/timeline */
export function UserDetailSheet({ username, open, onOpenChange }: UserDetailSheetProps) {
const detail = useQuery({
queryKey: ['telemt', 'user', username],
queryFn: () => api(`/api/telemt/users/${encodeURIComponent(username!)}`),
enabled: open && Boolean(username),
})
const events = useQuery({
queryKey: ['telemt', 'events', 'user-detail'],
queryFn: () =>
api('/api/telemt/runtime/events/recent?limit=200').catch(() => null),
enabled: open && Boolean(username),
})
const tls = useQuery({
queryKey: ['telemt', 'tls-fingerprints'],
queryFn: () =>
api('/api/telemt/runtime/tls-fingerprints?limit=200').catch(() => null),
enabled: open && Boolean(username),
})
const user =
unwrapData<UserInfo>(detail.data) ??
(detail.data as UserInfo | undefined)
const eventPayload = asRecord(unwrapData(events.data) ?? events.data)
const eventList: ApiEventRecord[] = Array.isArray(eventPayload.events)
? (eventPayload.events as ApiEventRecord[])
: Array.isArray((asRecord(eventPayload.data)).events)
? ((asRecord(eventPayload.data)).events as ApiEventRecord[])
: []
const tlsPayload = asRecord(unwrapData(tls.data) ?? tls.data)
const tlsData = asRecord(tlsPayload.data ?? tlsPayload)
const byUser = Array.isArray(tlsData.by_user)
? (tlsData.by_user as TlsFingerprintRow[])
: []
const byIp = Array.isArray(tlsData.by_ip) ? (tlsData.by_ip as TlsFingerprintRow[]) : []
const timeline = user
? buildUserTimeline({
user,
events: eventList,
tlsRows: [
...byUser.filter((r) => r.scope === user.username),
...byIp.filter((r) =>
(user.active_unique_ips_list ?? []).includes(String(r.scope ?? '')),
),
],
})
: []
const activeIps = user?.active_unique_ips_list ?? []
const recentIps = user?.recent_unique_ips_list ?? []
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-lg">
<SheetHeader className="border-b pb-4">
<SheetTitle>{username ?? 'Пользователь'}</SheetTitle>
<SheetDescription>
Подключения, IP и таймлайн активности.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4 py-4">
{detail.isLoading || !user ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
<div className="flex flex-col gap-5">
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">Сводка</h3>
<Badge
variant={user.enabled === false ? 'warning-light' : 'success-light'}
size="sm"
>
{user.enabled === false ? 'Выкл' : 'Вкл'}
</Badge>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Соединения</dt>
<dd className="font-medium tabular-nums">
{formatNumber(user.current_connections)}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Трафик</dt>
<dd className="font-medium tabular-nums">
{formatBytes(user.total_octets)}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Активные IP</dt>
<dd className="font-medium tabular-nums">
{formatNumber(user.active_unique_ips)}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Недавние IP</dt>
<dd className="font-medium tabular-nums">
{formatNumber(user.recent_unique_ips)}
</dd>
</div>
</dl>
</section>
<Separator />
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<NetworkIcon className="text-muted-foreground size-4" aria-hidden />
<h3 className="text-sm font-medium">Активные IP</h3>
</div>
{activeIps.length === 0 ? (
<p className="text-muted-foreground text-sm">Сейчас нет активных IP</p>
) : (
<ul className="flex flex-col gap-2">
{activeIps.map((ip) => (
<li
key={ip}
className="bg-muted/40 flex items-center gap-2 rounded-md px-3 py-2 text-sm"
>
<MapPinIcon className="text-primary size-3.5 shrink-0" aria-hidden />
<span className="font-mono text-xs">{ip}</span>
<Badge variant="success-light" size="sm" className="ml-auto">
сейчас
</Badge>
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<MapPinIcon className="text-muted-foreground size-4" aria-hidden />
<h3 className="text-sm font-medium">Недавние IP</h3>
</div>
{recentIps.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет IP в окне recent</p>
) : (
<ul className="flex flex-col gap-2">
{recentIps.map((ip) => (
<li
key={ip}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono text-xs">{ip}</span>
{activeIps.includes(ip) ? (
<Badge variant="success-light" size="sm" className="ml-auto">
активен
</Badge>
) : null}
</li>
))}
</ul>
)}
</section>
<Separator />
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Таймлайн</h3>
{timeline.length === 0 ? (
<p className="text-muted-foreground text-sm">
Пока нет событий, TLS-наблюдений или IP для этого пользователя.
</p>
) : (
<Timeline defaultValue={1} className="px-1">
{timeline.slice(0, 24).map((item, index) => (
<TimelineItem key={item.id} step={index + 1}>
<TimelineHeader>
<TimelineDate>
{item.at != null ? formatEpoch(item.at) : 'без точного времени'}
</TimelineDate>
<TimelineTitle>{item.title}</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent className="text-muted-foreground break-all text-xs">
{item.detail}
</TimelineContent>
</TimelineItem>
))}
</Timeline>
)}
</section>
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,163 @@
import { useMemo } from 'react'
import { type ColumnDef, type Row } from '@tanstack/react-table'
import { EyeIcon, MoreHorizontalIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { Button } from '@telemt/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@telemt/ui/components/dropdown-menu'
import { formatBytes, formatNumber, type UserInfo } from '@/lib/telemt'
export function createUsersColumns(opts: {
onOpen: (user: UserInfo) => void
}): ColumnDef<UserInfo, unknown>[] {
return [
{
id: 'username',
accessorKey: 'username',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Пользователь" />
),
cell: ({ row }) => (
<button
type="button"
className="text-left font-medium hover:underline"
onClick={() => opts.onOpen(row.original)}
>
{row.original.username}
</button>
),
size: 180,
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<Badge
variant={row.original.enabled === false ? 'warning-light' : 'success-light'}
size="sm"
>
{row.original.enabled === false ? 'Выкл' : 'Вкл'}
</Badge>
),
size: 100,
},
{
id: 'current_connections',
accessorKey: 'current_connections',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Соединения" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.current_connections)}
</span>
),
size: 120,
},
{
id: 'active_unique_ips',
accessorKey: 'active_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Активные IP" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.active_unique_ips)}
</span>
),
size: 120,
},
{
id: 'recent_unique_ips',
accessorKey: 'recent_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Недавние IP" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.recent_unique_ips)}
</span>
),
size: 120,
},
{
id: 'total_octets',
accessorKey: 'total_octets',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Трафик" />
),
cell: ({ row }) => (
<span className="tabular-nums">{formatBytes(row.original.total_octets)}</span>
),
size: 120,
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => <UserRowActions row={row} onOpen={opts.onOpen} />,
size: 56,
},
]
}
function UserRowActions({
row,
onOpen,
}: {
row: Row<UserInfo>
onOpen: (user: UserInfo) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="size-8" aria-label="Действия" />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpen(row.original)}>
<EyeIcon className="size-4" />
Открыть карточку
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function useUsersFilterFields() {
return useMemo(
() => [
{
key: 'username',
label: 'Имя',
type: 'text' as const,
className: 'w-44',
placeholder: 'Поиск…',
},
{
key: 'enabled',
label: 'Статус',
type: 'select' as const,
className: 'w-36',
options: [
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
],
},
],
[],
)
}
@@ -0,0 +1,37 @@
"use client"
import { useState } from "react"
export function useCopyToClipboard({
timeout = 2000,
onCopy,
}: {
timeout?: number
onCopy?: () => void
} = {}) {
const [isCopied, setIsCopied] = useState(false)
const copyToClipboard = (value: string) => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
return
}
if (!value) return
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true)
if (onCopy) {
onCopy()
}
if (timeout !== 0) {
setTimeout(() => {
setIsCopied(false)
}, timeout)
}
}, console.error)
}
return { isCopied, copyToClipboard }
}
+203
View File
@@ -0,0 +1,203 @@
/** Helpers for Telemt Control API envelopes — ops UI, not raw JSON dumps. */
export interface TelemtEnvelope<T = unknown> {
ok?: boolean
data?: T
revision?: string
error?: { code?: string; message?: string }
}
export interface SummaryData {
uptime_seconds?: number
connections_total?: number
connections_current?: number
connections_bad_total?: number
configured_users?: number
connections_bad_by_class?: Array<{ class: string; total: number }>
handshake_failures_by_class?: Array<{ class: string; total: number }>
handshake_failures_by_stage?: Array<{ stage: string; total: number }>
handshake_timeouts_total?: number
}
export interface UserInfo {
username: string
enabled?: boolean
in_runtime?: boolean
current_connections?: number
active_unique_ips?: number
active_unique_ips_list?: string[]
recent_unique_ips?: number
recent_unique_ips_list?: string[]
total_octets?: number
expiration_rfc3339?: string | null
max_tcp_conns?: number | null
user_ad_tag?: string | null
data_quota_bytes?: number | null
links?: {
classic?: string[]
secure?: string[]
tls?: string[]
}
}
export interface ApiEventRecord {
seq?: number
ts_epoch_secs?: number
event_type?: string
context?: string
}
export interface TlsFingerprintRow {
scope?: string
ja3?: string
ja4?: string
total?: number
auth_success?: number
bad_or_probe?: number
first_seen_epoch_secs?: number
last_seen_epoch_secs?: number
}
export interface UserTimelineItem {
id: string
at: number | null
title: string
detail: string
kind: 'active_ip' | 'recent_ip' | 'tls' | 'event'
}
export interface CreateUserResponse {
user: UserInfo
secret: string
}
export function formatEpoch(secs: unknown): string {
const n = typeof secs === 'number' ? secs : Number(secs)
if (!Number.isFinite(n) || n <= 0) return '—'
return new Date(n * 1000).toLocaleString('ru-RU')
}
export function buildUserTimeline(opts: {
user: UserInfo
events?: ApiEventRecord[]
tlsRows?: TlsFingerprintRow[]
}): UserTimelineItem[] {
const username = opts.user.username
const active = new Set(opts.user.active_unique_ips_list ?? [])
const items: UserTimelineItem[] = []
for (const ip of opts.user.active_unique_ips_list ?? []) {
items.push({
id: `active-${ip}`,
at: Date.now() / 1000,
title: 'Активный IP',
detail: ip,
kind: 'active_ip',
})
}
for (const ip of opts.user.recent_unique_ips_list ?? []) {
if (active.has(ip)) continue
items.push({
id: `recent-${ip}`,
at: null,
title: 'Недавний IP',
detail: `${ip} · в окне recent`,
kind: 'recent_ip',
})
}
for (const row of opts.tlsRows ?? []) {
if (row.scope && row.scope !== username) continue
const when = row.last_seen_epoch_secs ?? row.first_seen_epoch_secs ?? null
items.push({
id: `tls-${row.ja4 ?? row.ja3 ?? when}`,
at: when,
title: 'TLS fingerprint',
detail: [
row.scope ? `scope ${row.scope}` : null,
row.ja4 ? `JA4 ${row.ja4.slice(0, 18)}` : null,
`наблюдений ${row.total ?? 0}`,
row.first_seen_epoch_secs
? `первый ${formatEpoch(row.first_seen_epoch_secs)}`
: null,
]
.filter(Boolean)
.join(' · '),
kind: 'tls',
})
}
for (const ev of opts.events ?? []) {
const ctx = String(ev.context ?? '')
if (!ctx.toLowerCase().includes(username.toLowerCase())) continue
items.push({
id: `ev-${ev.seq ?? ev.ts_epoch_secs}-${ev.event_type}`,
at: ev.ts_epoch_secs ?? null,
title: String(ev.event_type ?? 'Событие'),
detail: ctx,
kind: 'event',
})
}
return items.sort((a, b) => {
if (a.at == null && b.at == null) return 0
if (a.at == null) return 1
if (b.at == null) return -1
return b.at - a.at
})
}
export function unwrapData<T>(payload: unknown): T | undefined {
if (payload == null) return undefined
if (typeof payload === 'object' && 'data' in (payload as object)) {
return (payload as TelemtEnvelope<T>).data
}
return payload as T
}
export function normalizeUsers(payload: unknown): UserInfo[] {
const data = unwrapData<unknown>(payload)
if (Array.isArray(data)) return data as UserInfo[]
if (data && typeof data === 'object' && Array.isArray((data as { users?: unknown }).users)) {
return (data as { users: UserInfo[] }).users
}
return []
}
export function formatNumber(value: unknown): string {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n)) return '—'
return new Intl.NumberFormat('ru-RU').format(n)
}
export function formatUptime(seconds: unknown): string {
const n = typeof seconds === 'number' ? seconds : Number(seconds)
if (!Number.isFinite(n) || n < 0) return '—'
const total = Math.floor(n)
const d = Math.floor(total / 86400)
const h = Math.floor((total % 86400) / 3600)
const m = Math.floor((total % 3600) / 60)
if (d > 0) return `${d}д ${h}ч`
if (h > 0) return `${h}ч ${m}м`
return `${m}м`
}
export function formatBytes(octets: unknown): string {
const n = typeof octets === 'number' ? octets : Number(octets)
if (!Number.isFinite(n) || n < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
+2 -2
View File
@@ -64,13 +64,13 @@ function ClientsPage() {
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Клиенты"
description="Managed clients каталог (fleet) / sync в Telemt (standalone)."
description="Каталог managed clients и синхронизация с Telemt."
/>
<Frame>
<FrameHeader>
<FrameTitle>Создать</FrameTitle>
<FrameDescription>
В standalone сразу POST /v1/users. Preview: form-7.
В standalone пользователь сразу создаётся на локальном Telemt.
</FrameDescription>
</FrameHeader>
<FramePanel>
+118 -39
View File
@@ -1,20 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, ServerIcon, UsersIcon } from 'lucide-react'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
ActivityIcon,
ClockIcon,
ServerIcon,
ShieldAlertIcon,
UsersIcon,
} from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, RankedBarList, StatusBadge } from '@/components/metric-list'
import { UI_SURFACE } from '@/lib/ui-surface'
import { api } from '@/lib/api-client'
import {
formatNumber,
formatUptime,
normalizeUsers,
unwrapData,
type SummaryData,
} from '@/lib/telemt'
/** @see https://reui.io/preview/base/stats-12 */
/** Dashboard KPI — ReUI stats-12 hybrid. Preview: https://reui.io/preview/base/stats-12 */
export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
})
@@ -22,10 +29,20 @@ export const Route = createFileRoute('/dashboard')({
function DashboardPage() {
const summary = useQuery({
queryKey: ['telemt', 'summary'],
queryFn: () => api('/api/telemt/stats/summary'),
refetchInterval: 10_000,
})
const users = useQuery({
queryKey: ['telemt', 'users'],
queryFn: () => api('/api/telemt/users'),
refetchInterval: 15_000,
})
const connSummary = useQuery({
queryKey: ['telemt', 'conn-summary'],
queryFn: () =>
api<{ ok?: boolean; data?: Record<string, unknown> }>('/api/telemt/stats/summary').catch(
() => null,
),
api('/api/telemt/runtime/connections/summary').catch(() => null),
refetchInterval: 10_000,
})
@@ -35,57 +52,119 @@ function DashboardPage() {
api<Array<{ id: string; status: string }>>('/api/servers').catch(() => []),
})
const data = summary.data?.data ?? {}
const data = unwrapData<SummaryData>(summary.data) ?? {}
const userRows = normalizeUsers(users.data)
const online = (servers.data ?? []).filter((s) => s.status === 'online').length
const live =
unwrapData<{
data?: { totals?: { current_connections?: number; active_users?: number } }
totals?: { current_connections?: number; active_users?: number }
}>(connSummary.data)
const liveTotals = live?.data?.totals ?? live?.totals
const currentConnections =
liveTotals?.current_connections ??
data.connections_current ??
data.connections_total
const configuredUsers = data.configured_users ?? userRows.length
const activeUsers =
liveTotals?.active_users ??
userRows.filter((u) => (u.current_connections ?? 0) > 0).length
const telemtOk = summary.isSuccess && !summary.isError
const badClasses = (data.connections_bad_by_class ?? [])
.map((c) => ({ label: c.class, value: Number(c.total) || 0 }))
.sort((a, b) => b.value - a.value)
.slice(0, 6)
const hsClasses = (data.handshake_failures_by_class ?? [])
.map((c) => ({ label: c.class, value: Number(c.total) || 0 }))
.sort((a, b) => b.value - a.value)
.slice(0, 6)
const items: KpiStatItem[] = [
{
id: 'servers',
label: 'Серверы',
value: String(servers.data?.length ?? '—'),
hint: `${online} online`,
value: formatNumber(servers.data?.length),
hint: `${online} онлайн`,
icon: <ServerIcon className="size-5 text-primary" />,
},
{
id: 'conns',
label: 'Соединения',
value: String(data.connections ?? data.total_connections ?? '—'),
hint: 'Telemt summary',
value: formatNumber(currentConnections),
hint: `всего принято ${formatNumber(data.connections_total)}`,
icon: <ActivityIcon className="size-5 text-primary" />,
},
{
id: 'users',
label: 'Users',
value: String(data.users ?? data.user_count ?? '—'),
hint: 'from /v1/stats/summary',
label: 'Пользователи',
value: formatNumber(configuredUsers),
hint: `${formatNumber(activeUsers)} активных`,
icon: <UsersIcon className="size-5 text-primary" />,
},
{
id: 'uptime',
label: 'Аптайм',
value: formatUptime(data.uptime_seconds),
hint: summary.isLoading ? 'Загрузка…' : telemtOk ? 'Telemt отвечает' : 'Нет связи',
icon: <ClockIcon className="size-5 text-primary" />,
variant: telemtOk ? 'default' : 'destructive',
},
]
return (
<div className="flex flex-col gap-4 md:gap-6" data-surface={UI_SURFACE}>
<PageHeader
title="Дашборд"
description="Telemt Panel — KPI hybrid (stats-12)."
description="Обзор узла Telemt: соединения, пользователи и ошибки."
/>
<KpiStatGrid items={items} />
<Frame>
<FrameHeader>
<FrameTitle>Статус API</FrameTitle>
<FrameDescription>
{summary.isError
? 'Telemt недоступен — проверьте TELEMT_API_URL / whitelist'
: summary.isLoading
? 'Загрузка…'
: 'Данные с Control API /v1'}
</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(summary.data ?? { pending: true }, null, 2)}
</pre>
</FramePanel>
</Frame>
<KpiStatGrid items={items} isLoading={summary.isLoading && !summary.data} />
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame
title="Состояние"
description="Ключевые показатели узла"
trailing={<StatusBadge ok={telemtOk} />}
>
<MetricRow label="Аптайм" value={formatUptime(data.uptime_seconds)} />
<MetricRow
label="Соединения сейчас"
value={formatNumber(currentConnections)}
/>
<MetricRow
label="Соединений всего"
value={formatNumber(data.connections_total)}
/>
<MetricRow
label="Плохие соединения"
value={formatNumber(data.connections_bad_total)}
/>
<MetricRow
label="Таймауты handshake"
value={formatNumber(data.handshake_timeouts_total)}
/>
<MetricRow label="Пользователей в конфиге" value={formatNumber(configuredUsers)} />
</MetricListFrame>
<MetricListFrame
title="Ошибки соединений"
description="Топ классов отказов"
trailing={
<ShieldAlertIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
}
>
<RankedBarList items={badClasses} emptyLabel="Ошибок соединений нет" />
</MetricListFrame>
<MetricListFrame
title="Ошибки handshake"
description="Топ классов handshake"
className="md:col-span-2"
>
<RankedBarList items={hsClasses} emptyLabel="Ошибок handshake нет" />
</MetricListFrame>
</div>
</div>
)
}
+148 -32
View File
@@ -1,17 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
import { asRecord, formatNumber, unwrapData } from '@/lib/telemt'
/** @see https://reui.io/docs/components/base/frame */
/** Runtime overview — Frame metrics, no raw JSON. Docs: https://reui.io/docs/components/base/frame */
export const Route = createFileRoute('/runtime')({
component: RuntimePage,
})
@@ -20,37 +16,157 @@ function RuntimePage() {
const mePool = useQuery({
queryKey: ['telemt', 'me_pool'],
queryFn: () => api('/api/telemt/runtime/me_pool_state').catch(() => null),
refetchInterval: 10_000,
})
const gates = useQuery({
queryKey: ['telemt', 'gates'],
queryFn: () => api('/api/telemt/runtime/gates').catch(() => null),
refetchInterval: 10_000,
})
const connSummary = useQuery({
queryKey: ['telemt', 'conn-summary'],
queryFn: () =>
api('/api/telemt/runtime/connections/summary').catch(() => null),
refetchInterval: 10_000,
})
const events = useQuery({
queryKey: ['telemt', 'events'],
queryFn: () => api('/api/telemt/runtime/events/recent').catch(() => null),
queryFn: () =>
api('/api/telemt/runtime/events/recent').catch(() => null),
refetchInterval: 15_000,
})
const pool = asRecord(unwrapData(mePool.data) ?? mePool.data)
const gateData = asRecord(unwrapData(gates.data) ?? gates.data)
const conn = asRecord(unwrapData(connSummary.data) ?? connSummary.data)
const connPayload = asRecord(conn.data ?? conn)
const totals = asRecord(connPayload.totals)
const topByConn = Array.isArray((connPayload.top as { by_connections?: unknown })?.by_connections)
? ((connPayload.top as { by_connections: Array<Record<string, unknown>> }).by_connections)
: []
const eventPayload = asRecord(unwrapData(events.data) ?? events.data)
const eventList = Array.isArray(eventPayload.events)
? (eventPayload.events as Array<Record<string, unknown>>)
: Array.isArray(eventPayload.data)
? (eventPayload.data as Array<Record<string, unknown>>)
: Array.isArray((eventPayload.data as { events?: unknown })?.events)
? ((eventPayload.data as { events: Array<Record<string, unknown>> }).events)
: []
const accepting = gateData.accepting_new_connections === true
const meReady = gateData.me_runtime_ready === true || pool.status === 'ready'
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Runtime" description="ME pool / events — Telemt runtime endpoints." />
<Frame>
<FrameHeader>
<FrameTitle>ME pool state</FrameTitle>
<FrameDescription>/v1/runtime/me_pool_state</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(mePool.data ?? { pending: mePool.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Recent events</FrameTitle>
<FrameDescription>/v1/runtime/events/recent</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(events.data ?? { pending: events.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
<PageHeader
title="Runtime"
description="Живое состояние прокси: шлюзы, соединения и события."
/>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame
title="Шлюзы"
description="Admission и ME"
trailing={<StatusBadge ok={accepting} okLabel="Приём открыт" failLabel="Приём закрыт" />}
>
<MetricRow
label="Новые соединения"
value={accepting ? 'Разрешены' : 'Запрещены'}
/>
<MetricRow
label="ME runtime"
value={
<Badge variant={meReady ? 'success-light' : 'warning-light'} size="sm">
{String(gateData.me_runtime_ready ?? pool.status ?? '—')}
</Badge>
}
/>
<MetricRow label="Режим маршрута" value={String(gateData.route_mode ?? '—')} />
<MetricRow
label="Reroute active"
value={gateData.reroute_active === true ? 'Да' : 'Нет'}
/>
<MetricRow label="Startup" value={String(gateData.startup_status ?? '—')} />
</MetricListFrame>
<MetricListFrame title="Соединения" description="Текущая нагрузка">
<MetricRow
label="Сейчас"
value={formatNumber(totals.current_connections)}
/>
<MetricRow label="Через ME" value={formatNumber(totals.current_connections_me)} />
<MetricRow
label="Direct"
value={formatNumber(totals.current_connections_direct)}
/>
<MetricRow label="Активные пользователи" value={formatNumber(totals.active_users)} />
</MetricListFrame>
<MetricListFrame
title="Топ по соединениям"
description="Пользователи с наибольшим числом сессий"
className="md:col-span-2"
>
{topByConn.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет активных пользователей</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4 font-medium">Пользователь</th>
<th className="py-2 pr-4 font-medium">Соединения</th>
<th className="py-2 font-medium">Октеты</th>
</tr>
</thead>
<tbody>
{topByConn.slice(0, 10).map((row) => (
<tr key={String(row.username)} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{String(row.username)}</td>
<td className="py-2 pr-4 tabular-nums">
{formatNumber(row.current_connections)}
</td>
<td className="py-2 tabular-nums">{formatNumber(row.total_octets)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</MetricListFrame>
<MetricListFrame
title="Недавние события"
description="Последние runtime-события"
className="md:col-span-2"
>
{eventList.length === 0 ? (
<p className="text-muted-foreground text-sm">Событий пока нет</p>
) : (
<div className="flex flex-col gap-2">
{eventList.slice(-12).reverse().map((ev, i) => (
<div
key={String(ev.seq ?? i)}
className="flex flex-col gap-0.5 border-b border-border/50 py-2 last:border-b-0"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{String(ev.event_type ?? 'event')}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{ev.ts_epoch_secs
? new Date(Number(ev.ts_epoch_secs) * 1000).toLocaleString('ru-RU')
: '—'}
</span>
</div>
<p className="text-muted-foreground text-xs break-all">
{String(ev.context ?? '')}
</p>
</div>
))}
</div>
)}
</MetricListFrame>
</div>
</div>
)
}
+56 -20
View File
@@ -1,17 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
import { asRecord, formatNumber, unwrapData } from '@/lib/telemt'
/** @see https://reui.io/docs/components/base/frame */
/** Security posture — Frame metrics. Docs: https://reui.io/docs/components/base/frame */
export const Route = createFileRoute('/security')({
component: SecurityPage,
})
@@ -20,25 +16,65 @@ function SecurityPage() {
const posture = useQuery({
queryKey: ['telemt', 'security'],
queryFn: () => api('/api/telemt/security/posture').catch(() => null),
refetchInterval: 20_000,
})
const data = asRecord(unwrapData(posture.data) ?? posture.data)
const ok = posture.isSuccess && posture.data != null
const entries = Object.entries(data).filter(
([, v]) => v == null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean',
)
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Безопасность"
description="Telemt /v1/security/* через BFF (standalone) или agent proxy (fleet)."
description="Постура и ключевые флаги защиты узла."
/>
<Frame>
<FrameHeader>
<FrameTitle>Security posture</FrameTitle>
<FrameDescription>/v1/security/posture</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(posture.data ?? { pending: posture.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
<MetricListFrame
title="Постура"
description={
posture.isLoading
? 'Загрузка…'
: ok
? 'Снимок с узла'
: 'Telemt недоступен или endpoint не отвечает'
}
trailing={<StatusBadge ok={ok} okLabel="Данные получены" failLabel="Нет данных" />}
>
{entries.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет скалярных полей в ответе. Проверьте версию Telemt и whitelist API.
</p>
) : (
entries.map(([key, value]) => (
<MetricRow
key={key}
label={humanizeKey(key)}
value={renderValue(value)}
/>
))
)}
</MetricListFrame>
</div>
)
}
function humanizeKey(key: string): string {
return key.replaceAll('_', ' ')
}
function renderValue(value: unknown) {
if (typeof value === 'boolean') {
return (
<Badge variant={value ? 'success-light' : 'secondary'} size="sm">
{value ? 'Да' : 'Нет'}
</Badge>
)
}
if (typeof value === 'number') return formatNumber(value)
if (value == null) return '—'
return String(value)
}
+1 -1
View File
@@ -66,7 +66,7 @@ function ServersPage() {
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Серверы"
description="Флот агентов (PANEL_MODE=fleet). Preview: empty-state-12 / ResourcePage."
description="Агенты флота и enrollment-токены."
/>
<Frame>
+43 -37
View File
@@ -1,17 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { api } from '@/lib/api-client'
/** @see https://reui.io/preview/base/settings-16 · https://reui.io/docs/blocks */
/** Settings — Frame shell. Preview DNA: https://reui.io/preview/base/settings-16 */
export const Route = createFileRoute('/settings')({
component: SettingsPage,
})
@@ -26,43 +20,55 @@ function SettingsPage() {
queryKey: ['ready'],
queryFn: () =>
fetch('/ready')
.then((r) => r.json())
.then((r) => r.json() as Promise<{ ok?: boolean; telemt?: boolean; panelMode?: string }>)
.catch(() => null),
refetchInterval: 15_000,
})
const modeLabel =
config.data?.panelMode === 'fleet' ? 'Флот (агенты)' : 'Standalone (локальный Telemt)'
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Настройки"
description="Панель и режим. Preview: settings-16."
description="Режим панели и проверка доступности сервисов."
/>
<Frame>
<FrameHeader>
<FrameTitle>Режим</FrameTitle>
<FrameDescription>PANEL_MODE и issuer (чтение из API).</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground">panelMode</span>
<span className="font-medium">{config.data?.panelMode ?? '…'}</span>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground">issuer</span>
<span className="font-medium">{config.data?.issuer ?? '…'}</span>
</div>
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Ready</FrameTitle>
<FrameDescription>/ready SQLite + Telemt reachability</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(health.data ?? { pending: true }, null, 2)}
</pre>
</FramePanel>
</Frame>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame title="Панель" description="Текущая конфигурация">
<MetricRow label="Режим" value={modeLabel} />
<MetricRow label="Issuer" value={config.data?.issuer ?? '—'} />
</MetricListFrame>
<MetricListFrame
title="Доступность"
description="Состояние панели и Telemt"
trailing={
<StatusBadge
ok={Boolean(health.data?.ok)}
okLabel="Готово"
failLabel="Проблема"
/>
}
>
<MetricRow
label="Панель"
value={health.data?.ok ? 'Работает' : 'Нет ответа'}
/>
<MetricRow
label="Telemt"
value={
health.data?.telemt === true
? 'Доступен'
: health.data?.telemt === false
? 'Недоступен'
: '—'
}
/>
<MetricRow label="Режим API" value={health.data?.panelMode ?? '—'} />
</MetricListFrame>
</div>
</div>
)
}
+344 -56
View File
@@ -1,81 +1,369 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { UsersIcon } from 'lucide-react'
"use no memo"
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from '@tanstack/react-table'
import { FilterIcon, FilterXIcon, PlusIcon, UsersIcon } from 'lucide-react'
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { toast } from 'sonner'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
createFilter,
Filters,
type Filter,
} from '@/components/reui/filters'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { api } from '@/lib/api-client'
import { createUsersColumns, useUsersFilterFields } from '@/components/users/users-columns'
import { UserDetailSheet } from '@/components/users/user-detail-sheet'
import { applyFiltersToData, getActiveFilters } from '@/components/reui-kit/filter-utils'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@telemt/ui/components/dialog'
import { Separator } from '@telemt/ui/components/separator'
import { TooltipProvider } from '@telemt/ui/components/tooltip'
import { api, ApiError } from '@/lib/api-client'
import {
normalizeUsers,
unwrapData,
type CreateUserResponse,
type UserInfo,
} from '@/lib/telemt'
/** Users via Telemt /v1/users — @see https://reui.io/preview/base/data-grid-filtering-2 */
/**
* Users ReUI data-grid-base-2 DNA + detail sheet timeline.
* Preview: https://reui.io/preview/base/data-grid-base-2
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/timeline
*/
export const Route = createFileRoute('/users')({
component: UsersPage,
})
function UsersPage() {
const users = useQuery({
const qc = useQueryClient()
const [selectedUsername, setSelectedUsername] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [username, setUsername] = useState('')
const [secret, setSecret] = useState('')
const [createdSecret, setCreatedSecret] = useState<string | null>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'username', desc: false },
])
const [filters, setFilters] = useState<Filter[]>(() => [
createFilter('username', 'contains', ['']),
])
const usersQuery = useQuery({
queryKey: ['telemt', 'users'],
queryFn: async () => {
const res = await api<{ ok?: boolean; data?: unknown }>('/api/telemt/users')
const data = res.data
return Array.isArray(data) ? data : []
queryFn: () => api('/api/telemt/users'),
refetchInterval: 10_000,
})
const rows = useMemo(
() => normalizeUsers(usersQuery.data),
[usersQuery.data],
)
const filteredData = useMemo(() => {
return applyFiltersToData(rows, filters, (item, field) => {
if (field === 'enabled') return item.enabled === false ? 'off' : 'on'
return (item as unknown as Record<string, unknown>)[field]
})
}, [rows, filters])
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}, [filters])
const handleOpen = (user: UserInfo) => {
setSelectedUsername(user.username)
setSheetOpen(true)
}
const columns = useMemo(
() => createUsersColumns({ onOpen: handleOpen }),
[],
)
const filterFields = useUsersFilterFields()
const activeFilters = getActiveFilters(filters)
const table = useReactTable({
data: filteredData,
columns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.username,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const create = useMutation({
mutationFn: async () => {
const body: { username: string; secret?: string } = {
username: username.trim(),
}
if (secret.trim()) body.secret = secret.trim()
return api('/api/telemt/users', {
method: 'POST',
body: JSON.stringify(body),
})
},
onSuccess: (res) => {
const nested = unwrapData<CreateUserResponse>(res)
const data =
nested ??
(res && typeof res === 'object' && 'secret' in res
? (res as CreateUserResponse)
: null)
setCreatedSecret(data?.secret ?? null)
setUsername('')
setSecret('')
void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
toast.success('Пользователь создан')
if (!data?.secret) setCreateOpen(false)
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Не удалось создать')
},
})
const rows = (users.data ?? []) as Array<Record<string, unknown>>
function handleCreateSubmit(e: FormEvent) {
e.preventDefault()
if (!username.trim()) return
create.mutate()
}
function handleCreateOpenChange(next: boolean) {
setCreateOpen(next)
if (!next) {
setCreatedSecret(null)
setUsername('')
setSecret('')
}
}
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Пользователи" description="Telemt /v1/users (standalone BFF)." />
<Frame>
<FrameHeader>
<FrameTitle>Список</FrameTitle>
<FrameDescription>
Preview: data-grid-filtering-2 kit ResourcePage в следующих итерациях.
</FrameDescription>
</FrameHeader>
<FramePanel>
{users.isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : rows.length === 0 ? (
<EmptyState
icon={UsersIcon}
title="Нет пользователей"
description="Создайте пользователя через Telemt API или Clients."
centered={false}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">username</th>
<th className="py-2 pr-4">active</th>
<th className="py-2">connections</th>
</tr>
</thead>
<tbody>
{rows.map((u, i) => (
<tr key={String(u.username ?? i)} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{String(u.username ?? '—')}</td>
<td className="py-2 pr-4">{String(u.enabled ?? u.active ?? '—')}</td>
<td className="py-2 tabular-nums">
{String(u.connections ?? u.active_connections ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
<PageHeader
title="Пользователи"
description="Учётные записи Telemt, фильтры и карточка с IP/таймлайном."
actions={
<Button type="button" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Создать
</Button>
}
/>
<TooltipProvider delay={0}>
<DataGrid
table={table}
recordCount={filteredData.length}
isLoading={usersQuery.isLoading}
onRowClick={(row) => handleOpen(row)}
tableLayout={{
dense: true,
width: 'auto',
headerSticky: true,
}}
>
<Frame className="w-full">
<FrameHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>Список пользователей</FrameTitle>
<FrameDescription>
{usersQuery.isLoading
? 'Загрузка…'
: `${filteredData.length} из ${rows.length}`}
</FrameDescription>
</div>
{activeFilters.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFilters([createFilter('username', 'contains', [''])])
}
>
<FilterXIcon className="size-3.5" />
Сбросить
</Button>
) : null}
</FrameHeader>
<FramePanel className="flex flex-col gap-0 p-0">
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<FilterIcon className="text-muted-foreground size-3.5" aria-hidden />
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="sm"
/>
</div>
{usersQuery.isError ? (
<div className="p-4">
<p className="text-destructive text-sm">
Не удалось загрузить пользователей.
</p>
</div>
) : filteredData.length === 0 && !usersQuery.isLoading ? (
<div className="p-4">
<EmptyState
icon={UsersIcon}
title="Нет пользователей"
description="Создайте первого или измените фильтры."
centered={false}
action={
<Button type="button" onClick={() => setCreateOpen(true)}>
Создать пользователя
</Button>
}
/>
</div>
) : (
<>
<DataGridScrollArea className="max-h-[min(70vh,720px)]">
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-xs">
Клик по строке открывает карточку
</span>
<DataGridPagination
sizes={[10, 20, 50]}
info="{from}{to} из {count}"
rowsPerPageLabel="Строк"
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
</TooltipProvider>
<UserDetailSheet
username={selectedUsername}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
<Dialog open={createOpen} onOpenChange={handleCreateOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{createdSecret ? 'Пользователь создан' : 'Новый пользователь'}
</DialogTitle>
<DialogDescription>
{createdSecret
? 'Сохраните секрет — повторно он не показывается.'
: 'Секрет можно задать (32 hex) или оставить пустым.'}
</DialogDescription>
</DialogHeader>
{createdSecret ? (
<div className="flex flex-col gap-3">
<code className="bg-muted break-all rounded-md p-3 text-xs">
{createdSecret}
</code>
<Button
type="button"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(createdSecret)
toast.success('Скопировано')
}}
>
Копировать секрет
</Button>
<DialogFooter>
<Button type="button" onClick={() => handleCreateOpenChange(false)}>
Готово
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleCreateSubmit} className="flex flex-col gap-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-username">Имя</FieldLabel>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
pattern="[A-Za-z0-9_.\-]+"
maxLength={64}
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="new-secret">Секрет (опц.)</FieldLabel>
<Input
id="new-secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
spellCheck={false}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleCreateOpenChange(false)}
>
Отмена
</Button>
<Button type="submit" disabled={create.isPending || !username.trim()}>
{create.isPending ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</form>
)}
</FramePanel>
</Frame>
</DialogContent>
</Dialog>
</div>
)
}
+4 -1
View File
@@ -11,5 +11,8 @@
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts"],
"exclude": [
"src/components/blocks/data-grid-base-2"
]
}
+14 -4
View File
@@ -15,6 +15,16 @@ UI: ReUI PRO Frame. Previews: [app-shell-12](https://reui.io/preview/base/app-sh
- Telemt с включённым Control API (`[server.api]`) для standalone
- Открытый порт панели (8080) или Traefik
### Обязательные переменные (production)
| Переменная | Требование |
|------------|------------|
| **`JWT_SECRET`** | **Обязателен.** ≥ 8 символов. Без него контейнер не стартует. Пример: `openssl rand -hex 32` |
| `BOOTSTRAP_PASSWORD` | Пароль первого admin (создаётся при пустой БД) |
| `TELEMT_API_URL` | URL Telemt Control API в режиме `standalone` |
Сохраните `JWT_SECRET` — смена секрета инвалидирует все сессии операторов.
## Быстрый старт: standalone (рекомендуется на Linux)
```bash
@@ -77,12 +87,12 @@ curl -fsSL https://panel.example.com/install-agent.sh | sudo bash -s -- \
| Переменная | Описание |
|------------|----------|
| `PANEL_MODE` | `standalone` \| `fleet` |
| `TELEMT_API_URL` | Base URL Control API (standalone) |
| `TELEMT_API_URL` | Base URL Control API (standalone), напр. `http://127.0.0.1:9091` |
| `TELEMT_AUTH_HEADER` | Опциональный `Authorization` к Telemt |
| `JWT_SECRET` | Секрет JWT операторов |
| `BOOTSTRAP_USERNAME` / `BOOTSTRAP_PASSWORD` | Первый admin при пустой БД |
| **`JWT_SECRET`** | **Обязателен в production.** Секрет подписи JWT операторов, **минимум 8 символов**. `openssl rand -hex 32` |
| `BOOTSTRAP_USERNAME` / `BOOTSTRAP_PASSWORD` | Первый admin при пустой БД (`BOOTSTRAP_PASSWORD` рекомендуется задать при первом запуске) |
| `PANEL_PUBLIC_URL` | Публичный URL (fleet) |
| `PANEL_ENCRYPTION_KEY` | Ключ для чувствительных данных (fleet) |
| `PANEL_ENCRYPTION_KEY` | Ключ для чувствительных данных (обязателен в fleet) |
| `DATABASE_URL` | По умолчанию `sqlite:/data/app.db` |
| `SERVER_PORT` | По умолчанию `8080` |
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@telemt/ui/lib/utils"
@@ -1,3 +1,5 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@telemt/ui/lib/utils"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@telemt/ui/lib/utils"