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.
This commit is contained in:
@@ -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,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).
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>)
|
||||
: {}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@telemt/ui/lib/utils"
|
||||
|
||||
Reference in New Issue
Block a user