Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ddce43f7 | ||
|
|
bcb1c48997 | ||
|
|
b20caef1f1 | ||
|
|
bf43f2846e | ||
|
|
f861d0fa8c | ||
|
|
2f914bcc65 | ||
|
|
7a10138990 | ||
|
|
391f34a53d | ||
|
|
ecd48642de | ||
|
|
09580bbcab | ||
|
|
4d54dacd05 | ||
|
|
a3562cc11e |
No files matched your search
@@ -52,8 +52,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
|
||||
# лежит глубже shallow-среза, и git diff падает с "bad object".
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
@@ -94,7 +96,14 @@ jobs:
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
|
||||
# а сигнал уйти в полный прогон через пустой diff.
|
||||
if git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
else
|
||||
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
|
||||
FILES=""
|
||||
fi
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"shiki": "^4.4.3",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function AnalyticsCardShell({
|
||||
actions={actions}
|
||||
footer={footer}
|
||||
className={cn('overflow-hidden', className)}
|
||||
contentClassName="flex flex-col gap-5 py-5"
|
||||
contentClassName="flex flex-col gap-5 px-5 py-5"
|
||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function syntheticSparkline(seed: number, points = 9): number[] {
|
||||
const base = Math.max(4, seed)
|
||||
return Array.from({ length: points }, (_, i) =>
|
||||
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
|
||||
)
|
||||
}
|
||||
|
||||
function buildMetrics({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiSparklineMetric[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Установлено / включено',
|
||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||
detail: loading ? '' : `${network.peersEstablished} сессий`,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
|
||||
sparkline: syntheticSparkline(bgpPct || 40),
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
title: 'Модули',
|
||||
label: 'Активные списки',
|
||||
value: loading ? '—' : `${enabledModules}`,
|
||||
delta: loading ? '…' : `${modules.length} всего`,
|
||||
deltaVariant: 'primary-light',
|
||||
detail: loading ? '' : 'маршрутизация',
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(enabledModules || 3),
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
title: 'Спикеры',
|
||||
label: 'В сети / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все в сети'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
detail: loading ? '' : 'live-снимок',
|
||||
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
|
||||
sparkline: syntheticSparkline(network.speakersOnline || 2),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
title: 'Задачи',
|
||||
label: 'Активные / ошибки',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
|
||||
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
|
||||
detail: loading ? '' : `${jobs.length} в выборке`,
|
||||
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
|
||||
sparkline: syntheticSparkline(running + failedJobs || 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiSparklineRow({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const metrics = useMemo(
|
||||
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
|
||||
[modules, peers, speakers, jobs, loading],
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { DohProfile } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
|
||||
]
|
||||
|
||||
export function DirectoriesDohGrid({
|
||||
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="DoH профили"
|
||||
title="DoH-профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
|
||||
isLoading={isLoading}
|
||||
primaryAction={actions}
|
||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
||||
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Network,
|
||||
Cog,
|
||||
ListChecks,
|
||||
Activity,
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
/** Explicit permission override; when omitted derived from `to`. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'Метрики, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||
function useVisibleNavGroups(): NavGroup[] {
|
||||
if (!isAuthEnabled()) return NAV_GROUPS
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
const perm = item.permission ?? permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
}),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {
|
||||
'/modules/new': '/modules',
|
||||
}
|
||||
|
||||
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
id: item.to,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
to: item.to,
|
||||
search: item.search,
|
||||
keywords: [item.to.replace(/^\//, '')],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||||
* @see https://reui.io/preview/base/app-shell-12
|
||||
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleGroups = useVisibleNavGroups()
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const pageTitle =
|
||||
pathname.startsWith('/modules/') && pathname !== '/modules/new'
|
||||
? 'Модуль'
|
||||
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{visibleGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} search={item.search} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset id="main-content">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{isAuthEnabled() ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Kbd } from '@evobgp/ui/components/kbd'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
items: CommandPaletteItem[]
|
||||
className?: string
|
||||
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||
hotkeyOnly?: boolean
|
||||
}
|
||||
|
||||
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((item) => {
|
||||
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [items, query])
|
||||
|
||||
function go(item: CommandPaletteItem) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
void navigate({ to: item.to, search: item.search })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hotkeyOnly ? (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Быстрый переход</DialogTitle>
|
||||
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Раздел или действие…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
onClick={() => go(item)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.description ? (
|
||||
<span className="text-muted-foreground text-xs">{item.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
ExternalLinkIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
|
||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalToken,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Светлая',
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Тёмная',
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'Системная',
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
] as const
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(source: string): string {
|
||||
const base = source.trim()
|
||||
if (!base) return '?'
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const authOn = isAuthEnabled()
|
||||
const claims = getClaims()
|
||||
|
||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||
const email =
|
||||
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
|
||||
const fallback = initials(name || email)
|
||||
|
||||
function handleSignOut() {
|
||||
if (authOn) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
return
|
||||
}
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<a href={authPortalUrl()} />}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden />
|
||||
Открыть Auth Portal
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOutIcon aria-hidden />
|
||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ActivityIcon,
|
||||
BookTextIcon,
|
||||
BoxesIcon,
|
||||
CogIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
ListChecksIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||
import { can, getClaims, isAuthEnabled, permissionForPath, redirectToPortalLogout, clearPortalToken, resetPortalHandoff } from '@/lib/auth'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
overviewJobsQueryOptions,
|
||||
overviewModulesQueryOptions,
|
||||
overviewPeersQueryOptions,
|
||||
} from '@/queries/overview'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { SidebarInset, SidebarProvider } from '@evobgp/ui/components/sidebar'
|
||||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
|
||||
const FRESH: Record<string, { badge: string; text: string }> = {
|
||||
'/dashboard': { badge: 'Live', text: 'GET /v1/modules · /v1/peers · /v1/jobs · данные обновляются при открытии страницы' },
|
||||
'/modules': { badge: 'Каталог', text: 'GET /v1/modules · списки префиксов и AS' },
|
||||
'/lookup': { badge: 'Проверка', text: 'GET /v1/lookup · IP или домен по спискам и community' },
|
||||
'/network': { badge: 'BGP', text: 'GET /v1/peers · /v1/speakers · состояние сессий опрашивается живьём' },
|
||||
'/directories': { badge: 'Справочники', text: 'BGP community и DoH-профили' },
|
||||
'/operations': { badge: 'Ревизии', text: 'GET /v1/revisions · /v1/jobs · применение и откат' },
|
||||
'/schedule': { badge: 'Задачи', text: 'Расписание обновления модулей' },
|
||||
'/monitoring': { badge: 'Live', text: 'GET /health · /ready · BIRD через birdc' },
|
||||
'/access': { badge: 'API', text: 'API-ключи и роли доступа' },
|
||||
'/settings': { badge: 'Настройки', text: 'Интерфейс и BIRD' },
|
||||
}
|
||||
|
||||
function freshFor(pathname: string) {
|
||||
const key = Object.keys(FRESH)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
|
||||
return FRESH[key ?? '/dashboard']!
|
||||
}
|
||||
|
||||
function initialsOf(name: string) {
|
||||
const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
|
||||
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
const NAV: Array<Omit<NavItem, 'problem'>> = [
|
||||
{ id: 'dashboard', label: 'Панель', to: '/dashboard', icon: LayoutDashboardIcon, group: 'Обзор' },
|
||||
{ id: 'modules', label: 'Модули', to: '/modules', icon: BoxesIcon, group: 'Маршрутизация' },
|
||||
{ id: 'lookup', label: 'Проверка', to: '/lookup', icon: SearchIcon, group: 'Маршрутизация' },
|
||||
{ id: 'network', label: 'Сеть', to: '/network', icon: NetworkIcon, group: 'Маршрутизация' },
|
||||
{ id: 'directories', label: 'Справочники', to: '/directories', icon: BookTextIcon, group: 'Маршрутизация' },
|
||||
{ id: 'operations', label: 'Операции', to: '/operations', icon: CogIcon, group: 'Операции' },
|
||||
{ id: 'schedule', label: 'Задачи', to: '/schedule', icon: ListChecksIcon, group: 'Операции' },
|
||||
{ id: 'monitoring', label: 'Мониторинг', to: '/monitoring', icon: ActivityIcon, group: 'Операции' },
|
||||
{ id: 'access', label: 'Доступ', to: '/access', icon: KeyRoundIcon, group: 'Система' },
|
||||
{ id: 'settings', label: 'Настройки', to: '/settings', icon: SettingsIcon, group: 'Система' },
|
||||
]
|
||||
|
||||
function logout() {
|
||||
if (isAuthEnabled()) {
|
||||
clearPortalToken()
|
||||
resetPortalHandoff()
|
||||
redirectToPortalLogout()
|
||||
}
|
||||
}
|
||||
|
||||
/** Меню и данные оболочки EvoBGP: пункты по правам, точки проблем и монитор из живых запросов. */
|
||||
function useChromeData(): ChromeConfig {
|
||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
return useMemo<ChromeConfig>(() => {
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const net = aggregateNetworkMetrics(peers, [])
|
||||
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase())).length
|
||||
const peersDown = Math.max(0, net.peersEnabled - net.peersEstablished)
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
: null
|
||||
const bgpPct = net.peersEnabled > 0 ? Math.round((net.peersEstablished / net.peersEnabled) * 100) : 0
|
||||
|
||||
const visible = NAV.filter((item) => {
|
||||
if (!isAuthEnabled()) return true
|
||||
const perm = permissionForPath(item.to)
|
||||
return !perm || can(perm)
|
||||
})
|
||||
const problems: Record<string, NavItem['problem']> = {
|
||||
network: peersDown > 0 ? { tone: 'er', label: `${peersDown} пиров не в Established` } : undefined,
|
||||
schedule: failedJobs > 0 ? { tone: 'wn', label: `Ошибок задач: ${failedJobs}` } : undefined,
|
||||
monitoring: healthQ.isSuccess && !healthOk ? { tone: 'er', label: 'API не отвечает как норма' } : undefined,
|
||||
}
|
||||
const navItems: NavItem[] = visible.map((i) => ({ ...i, problem: problems[i.id] }))
|
||||
const groups = [...new Set(navItems.map((i) => i.group))]
|
||||
|
||||
const monitorItems: MonitorItem[] = []
|
||||
if (healthQ.isSuccess && !healthOk) {
|
||||
monitorItems.push({ id: 'health', tone: 'er', title: 'API EvoBGP не в норме', text: 'GET /health вернул ошибку', age: 'сейчас', to: '/monitoring' })
|
||||
}
|
||||
if (peersDown > 0) {
|
||||
monitorItems.push({ id: 'peers', tone: 'er', title: `Пиров не в Established: ${peersDown}`, text: `Включено ${net.peersEnabled}, поднято ${net.peersEstablished}`, age: 'сейчас', to: '/network' })
|
||||
}
|
||||
if (failedJobs > 0) {
|
||||
monitorItems.push({ id: 'jobs', tone: 'wn', title: `Ошибок задач: ${failedJobs}`, text: 'Проверьте расписание и журнал ревизий', age: 'недавно', to: '/schedule' })
|
||||
}
|
||||
if (monitorItems.length === 0) {
|
||||
monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, BGP-сессии и задачи без проблем', age: 'сейчас', to: '/monitoring' })
|
||||
}
|
||||
|
||||
const metrics: MonitorMetric[] = [
|
||||
{ l: 'Состояние API', v: healthOk ? 'норма' : '—', p: healthOk ? 100 : 0, tone: healthOk ? 'success' : 'warning' },
|
||||
{ l: 'BGP пиры', v: `${bgpPct}%`, p: bgpPct, tone: bgpPct >= 90 ? 'success' : 'warning' },
|
||||
{ l: 'BIRD сессии', v: birdRatio === null ? '—' : `${birdRatio}%`, p: birdRatio ?? 0, tone: birdRatio === 100 ? 'success' : 'warning' },
|
||||
{ l: 'Ошибки задач', v: String(failedJobs), p: Math.min(100, failedJobs * 10), tone: failedJobs > 0 ? 'warning' : 'success' },
|
||||
]
|
||||
|
||||
const claims = getClaims()
|
||||
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
|
||||
const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'локальный API-токен')
|
||||
|
||||
const objects: PaletteObject[] = [
|
||||
...modules.map<PaletteObject>((m) => ({
|
||||
id: `module-${m.id}`,
|
||||
group: 'Модули',
|
||||
label: m.name,
|
||||
hint: `${m.type}${m.enabled ? '' : ' · выключен'}`,
|
||||
dot: m.enabled ? 'ok' : 'mu',
|
||||
to: `/modules/${m.id}`,
|
||||
})),
|
||||
...peers.map<PaletteObject>((p) => ({
|
||||
id: `peer-${p.id}`,
|
||||
group: 'BGP-пиры',
|
||||
label: p.name ?? p.neighbor,
|
||||
hint: `${p.neighbor}${p.remote_asn ? ` · AS${p.remote_asn}` : ''}`,
|
||||
dot: p.session_state === 'Established' ? 'ok' : 'er',
|
||||
to: '/network',
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
navItems,
|
||||
navGroups: groups,
|
||||
brand: <AppSwitcher />,
|
||||
user: { name, email, initials: initialsOf(name || email) },
|
||||
securityTo: '/settings',
|
||||
onLogout: logout,
|
||||
fresh: freshFor,
|
||||
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/monitoring', footerLabel: 'Мониторинг' },
|
||||
palette: {
|
||||
actions: [
|
||||
{ id: 'act-module', label: 'Создать модуль', hint: 'M', icon: PlusIcon, to: '/modules/new' },
|
||||
{ id: 'act-lookup', label: 'Проверить IP или домен', hint: 'L', icon: SearchIcon, to: '/lookup' },
|
||||
{ id: 'act-ops', label: 'Открыть ревизии', hint: 'R', icon: CogIcon, to: '/operations' },
|
||||
],
|
||||
objects,
|
||||
assistantTo: '/lookup',
|
||||
example: 'Подсказка: «пиры не в Established»',
|
||||
},
|
||||
}
|
||||
}, [healthQ.data, healthQ.isSuccess, peersQ.data, jobsQ.data, modulesQ.data, birdQ.data])
|
||||
}
|
||||
|
||||
/** Оболочка EvoBGP: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
|
||||
export function PortalShell({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
|
||||
const mq = window.matchMedia('(min-width: 1100px)')
|
||||
const sync = () => setOpen(mq.matches)
|
||||
sync()
|
||||
mq.addEventListener('change', sync)
|
||||
setMounted(true)
|
||||
return () => mq.removeEventListener('change', sync)
|
||||
}, [])
|
||||
const config = useChromeData()
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalUiProvider>
|
||||
<ChromeConfigProvider value={config}>
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-5 p-4 md:p-6">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
</ChromeConfigProvider>
|
||||
</PortalUiProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const network = aggregateNetworkMetrics(peers, [])
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
: null
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'Состояние API',
|
||||
value: healthOk ? 'норма' : '—',
|
||||
unit: '',
|
||||
percent: healthOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: healthOk ? 'success' : 'destructive',
|
||||
alert: !healthOk,
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
label: 'BGP пиры',
|
||||
value: String(bgpPct),
|
||||
unit: '%',
|
||||
percent: bgpPct,
|
||||
icon: <Network aria-hidden />,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
|
||||
alert: bgpPct < 70 && network.peersEnabled > 0,
|
||||
},
|
||||
{
|
||||
id: 'bird',
|
||||
label: 'BIRD сессии',
|
||||
value: birdRatio !== null ? String(birdRatio) : '—',
|
||||
unit: birdRatio !== null ? '%' : '',
|
||||
percent: birdRatio ?? 0,
|
||||
icon: <Bird aria-hidden />,
|
||||
tone:
|
||||
birdRatio === null
|
||||
? 'info'
|
||||
: birdRatio >= 100
|
||||
? 'success'
|
||||
: birdRatio >= 50
|
||||
? 'warning'
|
||||
: 'destructive',
|
||||
alert: birdRatio !== null && birdRatio < 100,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки задач',
|
||||
value: String(failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 10),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
|
||||
{' · '}
|
||||
Пиров в каталоге:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
||||
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
LookupQueryKind,
|
||||
@@ -202,9 +203,7 @@ export function LookupAddStep({
|
||||
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||
mode="columns"
|
||||
labels={{
|
||||
search: 'Поиск',
|
||||
empty: 'Ничего не найдено',
|
||||
back: 'Назад',
|
||||
...CASCADER_LABELS_RU,
|
||||
rootLevel: 'Модули',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
{ value: 'master', label: 'Основной' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
|
||||
* Активный шаг выводится из фактических отметок времени задачи.
|
||||
* @see https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
const ruDateTime = (value: string | null | undefined) =>
|
||||
value ? new Date(value).toLocaleString('ru-RU') : undefined
|
||||
|
||||
function resultSubtitle(job: JobRow): string | undefined {
|
||||
if (job.error) return job.error
|
||||
const summary = job.meta?.apply_summary
|
||||
if (summary && typeof summary === 'object') {
|
||||
const message = (summary as Record<string, unknown>).message
|
||||
if (typeof message === 'string' && message !== '') return message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function JobTimeline({ job }: { job: JobRow }) {
|
||||
const trigger = jobTriggerRu(job.meta?.trigger)
|
||||
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
|
||||
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
|
||||
const subtitle = resultSubtitle(job)
|
||||
|
||||
return (
|
||||
<div className="py-4 ps-3 pe-2">
|
||||
<Timeline defaultValue={completed}>
|
||||
<TimelineItem step={1}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
|
||||
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
|
||||
</TimelineItem>
|
||||
<TimelineItem step={2}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
|
||||
<TimelineTitle>Взята в работу</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
</TimelineItem>
|
||||
<TimelineItem step={3}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
|
||||
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
|
||||
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { JobTimeline } from '@/components/operations/job-timeline'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
@@ -92,6 +93,7 @@ export function OperationsJobsGrid({
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Вид' },
|
||||
size: 320,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -104,6 +106,7 @@ export function OperationsJobsGrid({
|
||||
return <StatusBadge status={row.original.status} hint={hint} />
|
||||
},
|
||||
meta: { headerTitle: 'Статус' },
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
@@ -117,6 +120,7 @@ export function OperationsJobsGrid({
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
size: 190,
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
@@ -130,6 +134,7 @@ export function OperationsJobsGrid({
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Завершена' },
|
||||
size: 190,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -175,6 +180,8 @@ export function OperationsJobsGrid({
|
||||
pinLastColumn
|
||||
virtualization={items.length > 80}
|
||||
emptyState={{ title: 'Нет задач' }}
|
||||
getRowCanExpand={() => true}
|
||||
expandedContent={(row) => <JobTimeline job={row} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import type { JobRow } from '@/types/api'
|
||||
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
||||
|
||||
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
||||
queued: 'Очередь',
|
||||
queued: 'В очереди',
|
||||
running: 'Выполняются',
|
||||
succeeded: 'Успешные',
|
||||
failed: 'Ошибки',
|
||||
|
||||
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
|
||||
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
size: 340,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
size: 210,
|
||||
},
|
||||
{
|
||||
accessorKey: 'materialized_prefix_count',
|
||||
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префиксов' },
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||
title={`Откатиться к ревизии ${row.original.id}?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evobgp/ui/components/sheet'
|
||||
import type { Tone } from './tone'
|
||||
import { TONE_BADGE } from './tone'
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
||||
|
||||
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
|
||||
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
|
||||
const glyph = TONE_GLYPH[tone]
|
||||
return (
|
||||
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
|
||||
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Заголовок страницы: название, описание, метка свежести и действия. */
|
||||
export function PageHead({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
sub: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{sub}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
const ALERT_ICON = {
|
||||
er: <CircleAlertIcon aria-hidden />,
|
||||
wn: <TriangleAlertIcon aria-hidden />,
|
||||
in: <InfoIcon aria-hidden />,
|
||||
} as const
|
||||
|
||||
const ALERT_VARIANT = {
|
||||
er: 'destructive',
|
||||
wn: 'warning',
|
||||
in: 'info',
|
||||
} as const
|
||||
|
||||
export function SignalAlert({
|
||||
tone,
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
tone: 'er' | 'wn' | 'in'
|
||||
title: string
|
||||
children: ReactNode
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Alert variant={ALERT_VARIANT[tone]}>
|
||||
{ALERT_ICON[tone]}
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
{action ? <AlertAction>{action}</AlertAction> : null}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
||||
export function PortalSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
dirty,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
|
||||
size?: 'md' | 'lg'
|
||||
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
|
||||
dirty?: boolean
|
||||
}) {
|
||||
const [touchedState, setTouched] = useState(false)
|
||||
const touched = dirty ?? touchedState
|
||||
const [guard, setGuard] = useState(false)
|
||||
|
||||
function requestChange(next: boolean) {
|
||||
if (!next && touched) {
|
||||
setGuard(true)
|
||||
return
|
||||
}
|
||||
if (!next) setTouched(false)
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={requestChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
|
||||
'data-[side=right]:sm:max-w-none',
|
||||
size === 'lg'
|
||||
? 'data-[side=right]:w-[min(45rem,100vw)]'
|
||||
: 'data-[side=right]:w-[min(30rem,100vw)]',
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Закрыть"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</SheetClose>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
|
||||
onInput={() => setTouched(true)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<SheetFooter
|
||||
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
|
||||
onClickCapture={(e) => {
|
||||
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
|
||||
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
|
||||
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
|
||||
e.stopPropagation()
|
||||
setGuard(true)
|
||||
}
|
||||
}}
|
||||
>{footer}</SheetFooter>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={guard} onOpenChange={setGuard}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Остаться</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setGuard(false)
|
||||
setTouched(false)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Закрыть без сохранения
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
|
||||
export function TypedConfirm({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
expected,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: ReactNode
|
||||
expected: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
const matches = value.trim().toLowerCase() === expected.toLowerCase()
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setValue('')
|
||||
onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="typed-confirm">
|
||||
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
|
||||
</Label>
|
||||
<Input
|
||||
id="typed-confirm"
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-invalid={value.length > 0 && !matches}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!matches}
|
||||
onClick={() => {
|
||||
setValue('')
|
||||
onConfirm()
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function KvFrame({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: ReactNode }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{item.label}</dt>
|
||||
<dd className="text-sm font-medium break-words">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Список «значение + подпись + копировать» (параметры issuer). */
|
||||
export function CopyList({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ label: string; value: string }>
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<ul className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={`Скопировать: ${item.label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
||||
toast.success(`${item.label} скопирован`)
|
||||
}}
|
||||
>
|
||||
Копировать
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/** Компактный список «ключ — значение» для боковых окон. */
|
||||
export function KvList({ items }: { items: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl className="flex flex-col divide-y rounded-lg border">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
|
||||
<dt className="text-muted-foreground text-xs">{k}</dt>
|
||||
<dd className="text-sm break-words">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
|
||||
export function Segmented({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
value: number
|
||||
onChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
||||
<TabsList className="max-w-full justify-start overflow-x-auto">
|
||||
{options.map((o, i) => (
|
||||
<TabsTrigger key={o} value={String(i)}>
|
||||
{o}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
|
||||
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
||||
return (
|
||||
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
|
||||
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
||||
export function PageTabsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
|
||||
{children}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||
{label}
|
||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
||||
<b className="text-sm font-medium">{title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{description}</span>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
||||
export function FormFrame({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
tone = 'ok',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
badge: string
|
||||
tone?: Tone
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import type { Tone } from './tone'
|
||||
import { StatusBadge } from './bits'
|
||||
|
||||
export type AppCardItem = {
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
badge: string
|
||||
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
|
||||
meta: string
|
||||
icon: ReactNode
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
|
||||
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
|
||||
*/
|
||||
export function AppCards({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: AppCardItem[]
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||
</div>
|
||||
{action}
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-3">
|
||||
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
|
||||
{items.map((c) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
|
||||
{c.icon}
|
||||
</IconTile>
|
||||
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
|
||||
</span>
|
||||
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
|
||||
{c.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
|
||||
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
|
||||
</>
|
||||
)
|
||||
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
|
||||
return (
|
||||
<li key={c.id} className="min-w-0">
|
||||
{c.onSelect ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={c.onSelect}
|
||||
aria-label={`${c.title}: ${c.badge}`}
|
||||
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div className={cls}>{body}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
mu: 'bg-muted-foreground',
|
||||
in: 'bg-chart-4',
|
||||
}
|
||||
|
||||
/**
|
||||
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
|
||||
* и строка «кто · источник · IP».
|
||||
*/
|
||||
export function ActivityFeed({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
<FrameDescription className="text-pretty">{description}</FrameDescription>
|
||||
</div>
|
||||
{action}
|
||||
</FrameHeader>
|
||||
<FramePanel className="py-1.5">
|
||||
<ol>
|
||||
{items.map((e) => (
|
||||
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
|
||||
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
|
||||
<span aria-hidden className="flex h-full flex-col items-center">
|
||||
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
|
||||
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="mr-2 inline-block align-middle">
|
||||
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
|
||||
</span>
|
||||
<b className="font-medium">{e.title}</b>
|
||||
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||
|
||||
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||
}
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* без сохранения настроек */
|
||||
}
|
||||
}
|
||||
|
||||
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||
|
||||
const setDensity = useCallback(
|
||||
(v: Density) => {
|
||||
setDensityState(v)
|
||||
writeStored(`${storageKey}:density`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const setContrast = useCallback(
|
||||
(v: Contrast) => {
|
||||
setContrastState(v)
|
||||
writeStored(`${storageKey}:contrast`, v)
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
const togglePin = useCallback(
|
||||
(id: string) => {
|
||||
setPins((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
writeStored(`${storageKey}:pins`, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset.density = density
|
||||
root.dataset.contrast = contrast
|
||||
return () => {
|
||||
delete root.dataset.density
|
||||
delete root.dataset.contrast
|
||||
}
|
||||
}, [density, contrast])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setPaletteOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<UiStore>(
|
||||
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||
)
|
||||
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
group: string
|
||||
/** Точка проблемы в углу иконки. */
|
||||
problem?: { tone: 'er' | 'wn'; label: string }
|
||||
}
|
||||
|
||||
export type MonitorItem = {
|
||||
id: string
|
||||
tone: 'er' | 'wn' | 'ok'
|
||||
title: string
|
||||
text: string
|
||||
age: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||
|
||||
export type PaletteAction = {
|
||||
id: string
|
||||
label: string
|
||||
/** Горячая буква справа. */
|
||||
hint: string
|
||||
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
to: string
|
||||
}
|
||||
|
||||
export type PaletteObject = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||
export type ChromeConfig = {
|
||||
navItems: NavItem[]
|
||||
navGroups: string[]
|
||||
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||
brand: ReactNode
|
||||
user: { name: string; email: string; initials: string }
|
||||
securityTo: string
|
||||
onLogout: () => void
|
||||
fresh: (pathname: string) => { badge: string; text: string }
|
||||
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||
}
|
||||
|
||||
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||
|
||||
export function useChromeConfig(): ChromeConfig {
|
||||
const ctx = useContext(ChromeConfigContext)
|
||||
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type Density = 'comfortable' | 'compact'
|
||||
export type Contrast = 'standard' | 'high'
|
||||
|
||||
export type UiStore = {
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
setDensity: (v: Density) => void
|
||||
contrast: Contrast
|
||||
setContrast: (v: Contrast) => void
|
||||
}
|
||||
|
||||
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||
|
||||
export function useUiStore(): UiStore {
|
||||
const ctx = useContext(UiStoreContext)
|
||||
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react'
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
InfoIcon,
|
||||
LogOutIcon,
|
||||
SearchIcon,
|
||||
StarIcon,
|
||||
MoonIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useTheme,
|
||||
} from 'next-themes'
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/reui/badge'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import {
|
||||
Button,
|
||||
} from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import {
|
||||
Kbd,
|
||||
} from '@evobgp/ui/components/kbd'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@evobgp/ui/components/popover'
|
||||
import {
|
||||
Separator,
|
||||
} from '@evobgp/ui/components/separator'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import {
|
||||
cn,
|
||||
} from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Segmented,
|
||||
} from './bits'
|
||||
import {
|
||||
useChromeConfig,
|
||||
useUiStore,
|
||||
type NavItem,
|
||||
} from './chrome-store'
|
||||
|
||||
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||
|
||||
function PortalNavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||
const { user, securityTo, onLogout } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||
className="bg-muted/40 aria-expanded:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="grid leading-tight">
|
||||
<span className="text-sm font-semibold">{user.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: securityTo })}
|
||||
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||
>
|
||||
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
Безопасность
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={LABEL_CLASS}>Тема</span>
|
||||
<Segmented
|
||||
label="Тема"
|
||||
options={['Тёмная', 'Светлая']}
|
||||
value={current === 'light' ? 1 : 0}
|
||||
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Плотность</span>
|
||||
<Segmented
|
||||
label="Плотность"
|
||||
options={['Обычная', 'Компактная']}
|
||||
value={density === 'compact' ? 1 : 0}
|
||||
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||
/>
|
||||
<span className={LABEL_CLASS}>Контраст</span>
|
||||
<Segmented
|
||||
label="Контраст"
|
||||
options={['Стандартный', 'Высокий']}
|
||||
value={contrast === 'high' ? 1 : 0}
|
||||
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden />
|
||||
Выйти
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||
|
||||
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||
aria-pressed={pinned}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { pins, togglePin } = useUiStore()
|
||||
const { navItems, navGroups, brand } = useChromeConfig()
|
||||
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="pb-1.5">
|
||||
{brand}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!closed[group]}
|
||||
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
||||
/>
|
||||
}
|
||||
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{group}
|
||||
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
||||
<SidebarMenu className="gap-px">
|
||||
{navItems.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
pinned={pins.includes(item.id)}
|
||||
onTogglePin={() => togglePin(item.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<PortalNavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||
function MonitorPopover() {
|
||||
const { monitor } = useChromeConfig()
|
||||
const MONITOR_ITEMS = monitor.items
|
||||
const MONITOR_METRICS = monitor.metrics
|
||||
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Состояние системы</b>
|
||||
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
|
||||
</div>
|
||||
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||
].map((x) => (
|
||||
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MONITOR_METRICS.map((m) => (
|
||||
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
||||
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
||||
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||
{monitor.footerLabel}
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function FreshnessPopover() {
|
||||
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||
const { fresh: freshFor } = useChromeConfig()
|
||||
const fresh = freshFor(pathname)
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
||||
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
||||
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function PortalHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = useUiStore()
|
||||
const { navItems, crumbFor } = useChromeConfig()
|
||||
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<FreshnessPopover />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Командная палитра (⌘K)"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
</Button>
|
||||
<MonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type PaletteEntry = {
|
||||
id: string
|
||||
group: string
|
||||
label: string
|
||||
hint: string
|
||||
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
dot?: 'ok' | 'er' | 'mu'
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||
|
||||
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||
export function CommandPalette() {
|
||||
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||
const { navItems, palette } = useChromeConfig()
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||
const go = (to: string) => () => void navigate({ to })
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
const list: PaletteEntry[] = [
|
||||
...[
|
||||
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||
]
|
||||
.filter((e) => match(e.label))
|
||||
.map((e) => ({ ...e, group: 'Действия' })),
|
||||
...navItems.filter((i) => match(i.label, i.group))
|
||||
.slice(0, 7)
|
||||
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
||||
...Object.values(
|
||||
palette.objects
|
||||
.filter((o) => match(o.label, o.hint))
|
||||
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||
]
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос на естественном языке',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||
run: () => void navigate({ to: palette.assistantTo }),
|
||||
} as PaletteEntry,
|
||||
...list,
|
||||
]
|
||||
}
|
||||
return list
|
||||
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
||||
shown.forEach((e, index) => {
|
||||
const list = map.get(e.group) ?? []
|
||||
list.push({ ...e, index })
|
||||
map.set(e.group, list)
|
||||
})
|
||||
return [...map.entries()]
|
||||
}, [shown])
|
||||
|
||||
function close() {
|
||||
setPaletteOpen(false)
|
||||
setQuery('')
|
||||
setActive(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
||||
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
||||
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
||||
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
||||
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
aria-label="Поиск и команды"
|
||||
placeholder="Раздел, пользователь, приложение или действие…"
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActive(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.max(a - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
shown[active]?.run()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||
) : (
|
||||
groups.map(([group, list]) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
||||
{list.map((e) => {
|
||||
const Icon = e.icon
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={e.index === active}
|
||||
onMouseEnter={() => setActive(e.index)}
|
||||
onClick={() => {
|
||||
e.run()
|
||||
close()
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
||||
>
|
||||
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
||||
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
||||
<span className="min-w-0 truncate">{e.label}</span>
|
||||
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
||||
<span>↑↓ выбор</span>
|
||||
<span>↵ открыть</span>
|
||||
<span>Esc закрыть</span>
|
||||
<span className="ml-auto">{palette.example}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export const DATA_GRID_CELL_PAD = 'py-3'
|
||||
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
|
||||
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
|
||||
|
||||
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
|
||||
headerRow: 'border-b border-border',
|
||||
bodyRow: 'group/row',
|
||||
}
|
||||
|
||||
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
|
||||
|
||||
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
|
||||
export function cellPad(index: number, count: number) {
|
||||
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
|
||||
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
|
||||
return DATA_GRID_CELL_PAD
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
|
||||
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Column } from '@tanstack/react-table'
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@evobgp/ui/components/input-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<Frame dense className={cn('w-full min-w-0', className)}>
|
||||
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
|
||||
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataPageToolbar({
|
||||
leading,
|
||||
segmented,
|
||||
filterControl,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
countLabel,
|
||||
actions,
|
||||
}: {
|
||||
leading?: ReactNode
|
||||
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
|
||||
filterControl?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{leading}
|
||||
{segmented ? <SegmentedControl {...segmented} /> : null}
|
||||
{filterControl}
|
||||
{onSearchChange ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataGridSortHeader<TData extends object>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<DataGridFeatures, TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
const sorted = column.getIsSorted()
|
||||
if (!column.getCanSort()) {
|
||||
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === 'asc' ? (
|
||||
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
|
||||
) : sorted === 'desc' ? (
|
||||
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
|
||||
) : (
|
||||
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
|
||||
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function parse(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** 12.09.2026 */
|
||||
export function fmtDate(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? DATE.format(d) : empty
|
||||
}
|
||||
|
||||
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
|
||||
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const startOfToday = new Date(now)
|
||||
startOfToday.setHours(0, 0, 0, 0)
|
||||
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
|
||||
if (diff === 0) return `сегодня ${TIME.format(d)}`
|
||||
if (diff === dayMs) return `вчера ${TIME.format(d)}`
|
||||
return `${SHORT.format(d)} ${TIME.format(d)}`
|
||||
}
|
||||
|
||||
/** Время дня 14:02. */
|
||||
export function fmtTime(iso: string | null | undefined, empty = '—') {
|
||||
const d = parse(iso)
|
||||
return d ? TIME.format(d) : empty
|
||||
}
|
||||
|
||||
/** «5 дн назад», «3 ч назад», «только что». */
|
||||
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
|
||||
const d = parse(iso)
|
||||
if (!d) return empty
|
||||
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
|
||||
if (sec < 60) return 'только что'
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
|
||||
return `${Math.floor(sec / 86400)} дн назад`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import type { KpiItem, KpiTone } from './kpi'
|
||||
|
||||
const VALUE: Record<KpiTone, string> = {
|
||||
neutral: 'text-foreground',
|
||||
ok: 'text-foreground',
|
||||
in: 'text-foreground',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
|
||||
const COLS: Record<number, string> = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2 max-[640px]:grid-cols-1',
|
||||
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
|
||||
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
|
||||
}
|
||||
|
||||
const TILE: Record<KpiTone, string> = {
|
||||
neutral: 'text-muted-foreground',
|
||||
ok: 'text-success',
|
||||
in: 'text-info',
|
||||
wn: 'text-warning',
|
||||
er: 'text-destructive',
|
||||
}
|
||||
|
||||
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
|
||||
|
||||
const BADGE = {
|
||||
neutral: 'outline',
|
||||
ok: 'success-light',
|
||||
in: 'info-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
|
||||
* в ячейке подпись, крупное значение и плашка-подсказка.
|
||||
*/
|
||||
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
|
||||
{items.map((k) => {
|
||||
const Icon = k.icon
|
||||
return (
|
||||
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground text-[13px]">{k.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
|
||||
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
|
||||
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
|
||||
{k.hint}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
|
||||
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
/** Плашка-подсказка рядом со значением. */
|
||||
hint: string
|
||||
tone: KpiTone
|
||||
/** Иконка показателя: плитка слева от подписи. */
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
|
||||
export function kpi(
|
||||
id: string,
|
||||
label: string,
|
||||
value: string,
|
||||
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
|
||||
): KpiItem {
|
||||
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
|
||||
import { DATA_GRID_I18N_RU as DATA_GRID_RU } from '@/lib/reui-i18n-ru'
|
||||
import { DataGrid, dataGridFeatures, type DataGridFeatures } 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 {
|
||||
DataGridTableDndRowHandle,
|
||||
DataGridTableDndRows,
|
||||
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import { Switch } from '@evobgp/ui/components/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { StatusBadge } from './bits'
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_LAYOUT,
|
||||
} from './data-page-layout'
|
||||
import {
|
||||
DataGridSortHeader,
|
||||
DataPageCard,
|
||||
DataPageToolbar,
|
||||
} from './data-page'
|
||||
import type { Tone } from './tone'
|
||||
import type { RowMark } from './tone'
|
||||
import { TONE_DOT } from './tone'
|
||||
|
||||
export type GridColumn<T> = {
|
||||
id: string
|
||||
title: string
|
||||
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
|
||||
size: number
|
||||
cell: (row: T) => ReactNode
|
||||
/** Ключ сортировки; без него колонка не сортируется. */
|
||||
sortValue?: (row: T) => string | number
|
||||
}
|
||||
|
||||
export type GridView<T> = { label: string; match: (row: T) => boolean }
|
||||
|
||||
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
|
||||
export type GridCard = {
|
||||
name: string
|
||||
sub?: string
|
||||
tone: Tone
|
||||
status: string
|
||||
cells: Array<{ l: string; v: string }>
|
||||
}
|
||||
|
||||
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
|
||||
export type RowMenuConfig = {
|
||||
onOpen?: () => void
|
||||
toggleLabel?: string
|
||||
onToggle?: () => void
|
||||
onDuplicate?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export type GridBulk = {
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
onRun?: (ids: string[]) => void
|
||||
offRun?: (ids: string[]) => void
|
||||
onDelete?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const SELECT_COLUMN_SIZE = 44
|
||||
const DRAG_COLUMN_SIZE = 44
|
||||
const ACTIONS_COLUMN_SIZE = 120
|
||||
|
||||
/**
|
||||
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
|
||||
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
|
||||
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
|
||||
*/
|
||||
export function SectionGrid<T extends object>({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
columns,
|
||||
getRowId,
|
||||
views,
|
||||
searchText,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
onRowClick,
|
||||
selectable = true,
|
||||
bulk,
|
||||
tools,
|
||||
card,
|
||||
rowSwitch,
|
||||
rowMenu,
|
||||
onReorder,
|
||||
rowStatus,
|
||||
pageSize = 50,
|
||||
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
rows: T[]
|
||||
columns: GridColumn<T>[]
|
||||
getRowId: (row: T) => string
|
||||
views?: GridView<T>[]
|
||||
searchText?: (row: T) => string
|
||||
searchPlaceholder?: string
|
||||
onRowClick?: (row: T) => void
|
||||
selectable?: boolean
|
||||
bulk?: GridBulk
|
||||
/** Кнопки действий в шапке; функция получает id выбранных строк. */
|
||||
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
|
||||
/** Включает переключатель «Таблица / Карточки». */
|
||||
card?: (row: T) => GridCard
|
||||
/** Переключатель «включено» в последней колонке (если у строки он есть). */
|
||||
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
|
||||
rowMenu?: (row: T) => RowMenuConfig
|
||||
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
|
||||
rowStatus?: (row: T) => RowMark | undefined
|
||||
pageSize?: number
|
||||
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
|
||||
footer?: string
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}) {
|
||||
const storageKey = `design-preview:view:${title}`
|
||||
const [view, setViewState] = useState(() => {
|
||||
try {
|
||||
const v = Number(window.localStorage.getItem(storageKey))
|
||||
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
const setView = (i: number) => {
|
||||
setViewState(i)
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(i))
|
||||
} catch {
|
||||
/* без сохранения вида */
|
||||
}
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
|
||||
const [hidden, setHidden] = useState<Record<string, boolean>>({})
|
||||
const [cards, setCards] = useState(false)
|
||||
|
||||
const data = useMemo(() => {
|
||||
const matchView = views?.[view]?.match
|
||||
const q = query.trim().toLowerCase()
|
||||
return rows.filter((row) => {
|
||||
if (matchView && !matchView(row)) return false
|
||||
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
}, [rows, views, view, query, searchText])
|
||||
|
||||
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
|
||||
|
||||
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
|
||||
const list: ColumnDef<DataGridFeatures, T>[] = []
|
||||
if (onReorder) {
|
||||
list.push({
|
||||
id: 'drag',
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
cell: () => <DataGridTableDndRowHandle />,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: DRAG_COLUMN_SIZE,
|
||||
})
|
||||
}
|
||||
if (selectable) {
|
||||
list.push({
|
||||
id: 'select',
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DataGridTableRowSelect row={row} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: SELECT_COLUMN_SIZE,
|
||||
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
|
||||
})
|
||||
}
|
||||
visibleColumns.forEach((col, i) => {
|
||||
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
|
||||
list.push({
|
||||
id: col.id,
|
||||
accessorFn: col.sortValue ?? (() => ''),
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
|
||||
cell: ({ row }) => col.cell(row.original),
|
||||
enableSorting: Boolean(col.sortValue),
|
||||
enableResizing: false,
|
||||
size: col.size,
|
||||
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
|
||||
})
|
||||
})
|
||||
list.push({
|
||||
id: '__actions',
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const on = rowSwitch?.value(r)
|
||||
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
|
||||
const soon = (what: string) => () => toast(`${what} (макет)`)
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{on !== undefined && rowSwitch ? (
|
||||
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
|
||||
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
|
||||
Удалить…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: ACTIONS_COLUMN_SIZE,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
})
|
||||
return list
|
||||
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
columns: defs,
|
||||
data,
|
||||
getRowId,
|
||||
state: { sorting, rowSelection, pagination },
|
||||
enableRowSelection: selectable,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
})
|
||||
|
||||
const dataIds = table.getRowModel().rows.map((r) => r.id)
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!onReorder || !active || !over || active.id === over.id) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
|
||||
const minWidth = Math.round(
|
||||
visibleColumns.reduce(
|
||||
(sum, c) => sum + c.size,
|
||||
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
|
||||
) * 0.7,
|
||||
)
|
||||
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
|
||||
const clear = () => setRowSelection({})
|
||||
const paged = data.length > pageSize
|
||||
const showCards = Boolean(card) && cards
|
||||
|
||||
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
i18n={DATA_GRID_RU}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
getRowStatus={rowStatus}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
|
||||
tableClassNames={DATA_GRID_CLASS_NAMES}
|
||||
>
|
||||
<DataPageCard className={className}>
|
||||
<h2 className="sr-only">{title}</h2>
|
||||
<DataPageToolbar
|
||||
segmented={
|
||||
views
|
||||
? {
|
||||
value: String(view),
|
||||
label: description,
|
||||
onChange: (v) => {
|
||||
setView(Number(v))
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
},
|
||||
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
leading={
|
||||
card ? (
|
||||
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
|
||||
<TabsList className="h-9! gap-0.5 p-[3px]">
|
||||
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
|
||||
Таблица
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
|
||||
Карточки
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : undefined
|
||||
}
|
||||
search={query}
|
||||
onSearchChange={
|
||||
searchText
|
||||
? (v) => {
|
||||
setQuery(v)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={countLabel}
|
||||
actions={
|
||||
<>
|
||||
{typeof tools === 'function' ? tools(selectedIds) : tools}
|
||||
{columns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
|
||||
>
|
||||
<Columns3Icon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-52">
|
||||
{columns.slice(1).map((c) => (
|
||||
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
|
||||
{c.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{selectable && selectedIds.length > 0 ? (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Массовые действия"
|
||||
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
|
||||
>
|
||||
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.onRun) bulk.onRun(selectedIds)
|
||||
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.onLabel ?? 'Включить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (bulk?.offRun) bulk.offRun(selectedIds)
|
||||
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
|
||||
clear()
|
||||
}}
|
||||
>
|
||||
{bulk?.offLabel ?? 'Выключить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (bulk?.onDelete) bulk.onDelete(selectedIds)
|
||||
else toast(`Удаление: ${selectedIds.length} (макет)`)
|
||||
}}
|
||||
>
|
||||
Удалить…
|
||||
</Button>
|
||||
<span className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={clear}>
|
||||
Снять выбор
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{showCards && card ? (
|
||||
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
|
||||
{data.map((r) => {
|
||||
const c = card(r)
|
||||
const id = getRowId(r)
|
||||
const marked = rowStatus?.(r)
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Выбрать ${c.name}`}
|
||||
checked={Boolean(rowSelection[id])}
|
||||
onChange={(e) =>
|
||||
setRowSelection((s) => {
|
||||
const next = { ...s }
|
||||
if (e.target.checked) next[id] = true
|
||||
else delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="accent-primary size-4"
|
||||
/>
|
||||
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRowClick?.(r)}
|
||||
className="flex min-w-0 items-start gap-2 text-left"
|
||||
aria-label={`Открыть: ${c.name}`}
|
||||
>
|
||||
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{c.name}</span>
|
||||
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
{c.cells.map((x) => (
|
||||
<div key={x.l} className="min-w-0">
|
||||
<dt className="text-muted-foreground">{x.l}</dt>
|
||||
<dd className="truncate font-mono">{x.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<DataGridScrollArea>
|
||||
<div style={{ minWidth }}>
|
||||
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||
</div>
|
||||
</DataGridScrollArea>
|
||||
)}
|
||||
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
|
||||
</DataPageCard>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
/** Двухстрочная ячейка: заголовок + подпись. */
|
||||
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
|
||||
|
||||
/** Пометка строки с накопленной правкой. */
|
||||
export type RowMark = 'new' | 'dirty' | 'deleted'
|
||||
|
||||
|
||||
type BadgeVariant =
|
||||
| 'success-light'
|
||||
| 'warning-light'
|
||||
| 'destructive-light'
|
||||
| 'info-light'
|
||||
| 'outline'
|
||||
|
||||
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
|
||||
ok: 'success-light',
|
||||
wn: 'warning-light',
|
||||
er: 'destructive-light',
|
||||
in: 'info-light',
|
||||
mu: 'outline',
|
||||
}
|
||||
|
||||
export const TONE_DOT: Record<Tone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
in: 'bg-success',
|
||||
mu: 'bg-muted-foreground/50',
|
||||
}
|
||||
@@ -9,18 +9,18 @@ import {
|
||||
} from './frame-data-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
||||
it('defaults макета: не dense, headerBackground true, width fixed', () => {
|
||||
const layout = kitDataGridTableLayout()
|
||||
expect(layout.dense).toBe(true)
|
||||
expect(layout.headerBackground).toBe(false)
|
||||
expect(layout.dense).toBe(false)
|
||||
expect(layout.headerBackground).toBe(true)
|
||||
expect(layout.width).toBe('fixed')
|
||||
expect(layout.headerSticky).toBe(false)
|
||||
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
|
||||
expect(layout.columnsPinnable).toBe(false)
|
||||
})
|
||||
|
||||
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
||||
it('kit tableClassNames: поля краёв как в MikrotikManager', () => {
|
||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-5 last:pe-4')
|
||||
expect(kitDataGridTableClassNames.base).toBe(
|
||||
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
)
|
||||
@@ -33,7 +33,7 @@ describe('kitDataGridTableLayout', () => {
|
||||
})
|
||||
expect(layout.width).toBe('auto')
|
||||
expect(layout.columnsPinnable).toBe(true)
|
||||
expect(layout.headerBackground).toBe(false)
|
||||
expect(layout.headerBackground).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
@@ -41,13 +40,12 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||
|
||||
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
||||
|
||||
@@ -66,10 +64,10 @@ export function kitDataGridTableLayout(
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
dense: opts.dense ?? true,
|
||||
dense: opts.dense ?? false,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
headerBackground: false,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: opts.width ?? ('fixed' as const),
|
||||
columnsVisibility: opts.columnsVisibility ?? false,
|
||||
@@ -84,7 +82,8 @@ export function kitDataGridTableLayout(
|
||||
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
||||
export const kitDataGridTableClassNames = {
|
||||
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||
edgeCell: 'first:ps-3 last:pe-3',
|
||||
edgeCell: 'first:ps-5 last:pe-4',
|
||||
headerRow: 'border-b border-border',
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -298,6 +297,7 @@ function FrameDataGridBody<TData extends object>({
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
i18n={DATA_GRID_I18N_RU}
|
||||
tableLayout={kitDataGridTableLayout({
|
||||
dense,
|
||||
width: tableWidth,
|
||||
@@ -315,10 +315,7 @@ function FrameDataGridBody<TData extends object>({
|
||||
)}
|
||||
{showPagination ? (
|
||||
<>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</FrameFooter>
|
||||
<DataGridPagination className="px-5 py-3" sizes={[10, 25, 50]} />
|
||||
</>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
|
||||
@@ -260,39 +260,61 @@ interface KpiStatGridProps {
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
|
||||
function stripCols(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @2xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
}
|
||||
|
||||
/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */
|
||||
function KpiStripCell({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
const clickable = Boolean(item.to || onActivate)
|
||||
const body = (
|
||||
<div className="flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||
{item.icon ? (
|
||||
<IconTile variant="outline" aria-hidden="true" className={cn('shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE_VARIANT_CLASS[valueVariant])}>
|
||||
{item.value}
|
||||
</span>
|
||||
{footer ? <span className="min-w-0 max-w-full">{footer}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const cellClass = cn(
|
||||
'bg-card relative min-w-0',
|
||||
clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
isSelected(item) && 'bg-muted/30',
|
||||
)
|
||||
if (item.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
<div className={cellClass}>
|
||||
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
|
||||
{body}
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
<div className={cellClass} onClick={onActivate} role="button" tabIndex={0} onKeyDown={(e) => handleCardKeyDown(onActivate, e)}>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
return <div className={cellClass}>{body}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,12 +364,14 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||
<FramePanel className="overflow-hidden p-0">
|
||||
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStripCell key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
useTable,
|
||||
type ExpandedState,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
@@ -10,7 +11,6 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-rea
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
StatusToggleGroup,
|
||||
type StatusToggleTone,
|
||||
} from '@/components/status-toggle-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
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 { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Filters } from '@/components/reui/filters/filters'
|
||||
import { SegmentedControl } from '@/components/portal/data-page'
|
||||
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
@@ -39,9 +40,8 @@ import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||
import {
|
||||
applyFiltersToData,
|
||||
@@ -94,6 +94,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
|
||||
| 'horizontalScroll'
|
||||
| 'pinLeftColumnIds'
|
||||
| 'columnPinControls'
|
||||
| 'expandedContent'
|
||||
| 'getRowCanExpand'
|
||||
>
|
||||
|
||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||
@@ -218,6 +220,8 @@ function ResourcePageSimple<T extends object>({
|
||||
horizontalScroll,
|
||||
pinLeftColumnIds,
|
||||
columnPinControls,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
}: ResourcePageProps<T>) {
|
||||
if (isLoading) return <ResourcePageSkeleton />
|
||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||
@@ -256,6 +260,8 @@ function ResourcePageSimple<T extends object>({
|
||||
height={height}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
expandedContent={expandedContent}
|
||||
getRowCanExpand={getRowCanExpand}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||
columnVisibilityTrigger={columnVisibilityTrigger}
|
||||
@@ -304,6 +310,8 @@ function ResourcePageFiltered<T extends object>({
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
horizontalScroll = false,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
}: ResourcePageProps<T>) {
|
||||
const headerActions = primaryAction ?? actions
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
@@ -339,6 +347,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
@@ -376,9 +385,28 @@ function ResourcePageFiltered<T extends object>({
|
||||
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
|
||||
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
|
||||
const tableColumns = useMemo(
|
||||
() => applyKitActionColumn(columns, { pinLastColumn }),
|
||||
[columns, pinLastColumn],
|
||||
() =>
|
||||
applyKitActionColumn(
|
||||
expandedContent
|
||||
? [
|
||||
{
|
||||
id: 'expand',
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { cellClassName: 'w-10', expandedContent },
|
||||
},
|
||||
...columns,
|
||||
]
|
||||
: columns,
|
||||
{ pinLastColumn },
|
||||
),
|
||||
[columns, pinLastColumn, expandedContent],
|
||||
)
|
||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||
pinLastColumn,
|
||||
@@ -399,6 +427,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
sorting,
|
||||
rowSelection,
|
||||
pagination,
|
||||
expanded,
|
||||
...(enablePinning ? { columnPinning } : {}),
|
||||
},
|
||||
initialState: enablePinning ? { columnPinning } : undefined,
|
||||
@@ -406,6 +435,10 @@ function ResourcePageFiltered<T extends object>({
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
onExpandedChange: setExpanded,
|
||||
getRowCanExpand: expandedContent
|
||||
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
@@ -495,6 +528,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage="Нет записей по выбранным фильтрам."
|
||||
onRowClick={onRowClick}
|
||||
i18n={DATA_GRID_I18N_RU}
|
||||
tableLayout={kitDataGridTableLayout({
|
||||
dense: true,
|
||||
width: 'fixed',
|
||||
@@ -531,74 +565,66 @@ function ResourcePageFiltered<T extends object>({
|
||||
) : null}
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{showSearch && primaryTextField ? (
|
||||
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
||||
aria-label={primaryTextField.label || 'Поиск'}
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
{showFiltersPopover ? (
|
||||
<Filters
|
||||
query={extraQuery}
|
||||
fields={extraFilterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
variant="advanced"
|
||||
advancedMode="popover"
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
{extraActiveCount > 0 ? (
|
||||
<Badge size="xs" variant="secondary" radius="full">
|
||||
{extraActiveCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||
{countedTabs.length > 0 ? (
|
||||
<SegmentedControl
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
label="Фильтр по статусу"
|
||||
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
|
||||
/>
|
||||
) : null}
|
||||
{showFiltersPopover ? (
|
||||
<Filters
|
||||
query={extraQuery}
|
||||
fields={extraFilterFields}
|
||||
onQueryChange={handleFiltersChange}
|
||||
variant="advanced"
|
||||
advancedMode="popover"
|
||||
size="default"
|
||||
labels={FILTERS_LABELS_RU}
|
||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Фильтры
|
||||
{extraActiveCount > 0 ? (
|
||||
<Badge size="xs" variant="secondary" radius="full">
|
||||
{extraActiveCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showSearch && primaryTextField ? (
|
||||
<InputGroup className="max-w-sm min-w-[220px]">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchText}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
||||
aria-label={primaryTextField.label || 'Поиск'}
|
||||
/>
|
||||
) : null}
|
||||
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
|
||||
<div />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{countedTabs.length > 0 ? (
|
||||
<StatusToggleGroup
|
||||
items={countedTabs}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
aria-label="Фильтр по статусу"
|
||||
/>
|
||||
) : null}
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} выбрано
|
||||
</Badge>
|
||||
) : null}
|
||||
{hasDirtyFilters ? (
|
||||
<Button type="button" variant="outline" onClick={handleClear}>
|
||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
{tableNode}
|
||||
@@ -607,11 +633,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
||||
</FrameFooter>
|
||||
<DataGridPagination className="px-5 py-3" sizes={[5, 10, 20, 50]} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||
import {
|
||||
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
|
||||
|
||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||
|
||||
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
||||
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
|
||||
// propagate to the components that call this hook. They opt out themselves.
|
||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||
count,
|
||||
getScrollElement,
|
||||
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualItemsProps) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
|
||||
overscan,
|
||||
activeIndex,
|
||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderLoader,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const { i18n } = useDataGrid()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
{i18n.labels.filterNoResults}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
}}
|
||||
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
|
||||
{i18n.labels.filterClear}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props } = useDataGrid()
|
||||
const { i18n, isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
// 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.
|
||||
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
|
||||
// to []), then every leaf it leaves out, in definition order - the same
|
||||
// completion TanStack applies when rendering, so a rendered neighbour is
|
||||
// always present to re-seat beside, even after columns are added later.
|
||||
const columnOrderState = table.state.columnOrder
|
||||
const columnOrder =
|
||||
columnOrderState.length > 0
|
||||
? columnOrderState
|
||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
||||
const definitionOrder = table
|
||||
.getAllColumns()
|
||||
.flatMap((topColumn) => topColumn.getLeafColumns())
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const columnOrder = [
|
||||
...columnOrderState,
|
||||
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
|
||||
]
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.state.columnVisibility)
|
||||
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
// Move neighbours come from what is RENDERED: the column's own pin bucket,
|
||||
// visible columns only. Stepping through the raw columnOrder would trade
|
||||
// places with a hidden or pinned column - an enabled click that moves nothing.
|
||||
// With grouping in TanStack's default "reorder" mode, grouped columns render
|
||||
// first whatever columnOrder says: they neither move nor serve as a target.
|
||||
const groupedColumnMode = (
|
||||
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
|
||||
).groupedColumnMode
|
||||
const isHoistedByGrouping = (target: object) =>
|
||||
groupedColumnMode !== false &&
|
||||
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
|
||||
(target as { getIsGrouped: () => boolean }).getIsGrouped()
|
||||
const renderedPeers = (
|
||||
isPinned === "start"
|
||||
? table.getStartVisibleLeafColumns()
|
||||
: isPinned === "end"
|
||||
? table.getEndVisibleLeafColumns()
|
||||
: table.getCenterVisibleLeafColumns()
|
||||
)
|
||||
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const renderedIndex = renderedPeers.indexOf(column.id)
|
||||
const leftNeighbour =
|
||||
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
|
||||
const rightNeighbour =
|
||||
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
|
||||
? renderedPeers[renderedIndex + 1]
|
||||
: undefined
|
||||
const canMoveLeft = leftNeighbour !== undefined
|
||||
const canMoveRight = rightNeighbour !== undefined
|
||||
|
||||
/** Re-seats this column beside a rendered neighbour; every other column,
|
||||
* hidden or pinned ones included, keeps its place in the full order. */
|
||||
const moveBeside = (neighbourId: string, side: "before" | "after") => {
|
||||
const newOrder = columnOrder.filter((id) => id !== column.id)
|
||||
const at = newOrder.indexOf(neighbourId)
|
||||
if (at === -1) return
|
||||
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||
{isPinned === "start" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||
{isPinned === "end" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
if (leftNeighbour) moveBeside(leftNeighbour, "before")
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
<span>{i18n.labels.moveColumnStart}</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
if (rightNeighbour) moveBeside(rightNeighbour, "after")
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
<span>{i18n.labels.moveColumnEnd}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
<span>{i18n.labels.columnsMenu}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
leftNeighbour,
|
||||
rightNeighbour,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
variant="ghost"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
|
||||
table: Table<DataGridFeatures, TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
{i18n.labels.toggleColumns}
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface DataGridI18nLabels {
|
||||
/* The column header menu. */
|
||||
sortAscending: string
|
||||
sortDescending: string
|
||||
pinColumnStart: string
|
||||
pinColumnEnd: string
|
||||
moveColumnStart: string
|
||||
moveColumnEnd: string
|
||||
columnsMenu: string
|
||||
unpinColumn: (title: string) => string
|
||||
toggleColumns: string
|
||||
/* Row and cell affordances. */
|
||||
rowCreate: string
|
||||
pinRow: string
|
||||
unpinRow: string
|
||||
selectRow: string
|
||||
selectAll: string
|
||||
expandRow: string
|
||||
collapseRow: string
|
||||
dragToReorder: string
|
||||
dragToReorderRow: string
|
||||
reorderingUnavailable: string
|
||||
/* Grid states. */
|
||||
loading: string
|
||||
empty: string
|
||||
allRowsLoaded: string
|
||||
/* Pagination. */
|
||||
rowsPerPage: string
|
||||
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||
previousPage: string
|
||||
nextPage: string
|
||||
goToPage: (page: number) => string
|
||||
paginationEllipsis: string
|
||||
/* The faceted column filter. */
|
||||
filterSelectedCount: (count: number) => string
|
||||
filterNoResults: string
|
||||
filterClear: string
|
||||
}
|
||||
|
||||
export interface DataGridI18nConfig {
|
||||
labels: DataGridI18nLabels
|
||||
}
|
||||
|
||||
export type DataGridI18nOverrides = {
|
||||
labels?: Partial<DataGridI18nLabels>
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||
sortAscending: "Asc",
|
||||
sortDescending: "Desc",
|
||||
pinColumnStart: "Pin to left",
|
||||
pinColumnEnd: "Pin to right",
|
||||
moveColumnStart: "Move to left",
|
||||
moveColumnEnd: "Move to right",
|
||||
columnsMenu: "Columns",
|
||||
unpinColumn: (title) => `Unpin ${title} column`,
|
||||
toggleColumns: "Toggle Columns",
|
||||
rowCreate: "Add row",
|
||||
pinRow: "Pin row",
|
||||
unpinRow: "Unpin row",
|
||||
selectRow: "Select row",
|
||||
selectAll: "Select all",
|
||||
expandRow: "Expand row",
|
||||
collapseRow: "Collapse row",
|
||||
dragToReorder: "Drag to reorder",
|
||||
dragToReorderRow: "Drag to reorder row",
|
||||
reorderingUnavailable: "Reordering unavailable",
|
||||
loading: "Loading...",
|
||||
empty: "No data available",
|
||||
allRowsLoaded: "All records loaded",
|
||||
rowsPerPage: "Rows per page",
|
||||
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||
previousPage: "Go to previous page",
|
||||
nextPage: "Go to next page",
|
||||
goToPage: (page) => `Go to page ${page}`,
|
||||
paginationEllipsis: "...",
|
||||
filterSelectedCount: (count) => `${count} selected`,
|
||||
filterNoResults: "No results found.",
|
||||
filterClear: "Clear filters",
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||
})
|
||||
|
||||
/**
|
||||
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||
* default back into a function-valued label the consumer replaced. With no
|
||||
* overrides the frozen default is returned as-is, so the merge is free to
|
||||
* run on every render without producing a new identity.
|
||||
*/
|
||||
export function mergeDataGridI18n(
|
||||
overrides?: DataGridI18nOverrides
|
||||
): DataGridI18nConfig {
|
||||
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||
return {
|
||||
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
/** Page buttons the truncated middle shows; the row adds first, last and
|
||||
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One slot in the page row: a page button, or an ellipsis that jumps over the
|
||||
* pages it stands for.
|
||||
*/
|
||||
type DataGridPaginationItem =
|
||||
| { type: "page"; index: number }
|
||||
| { type: "ellipsis"; direction: "previous"; target: number }
|
||||
| { type: "ellipsis"; direction: "next"; target: number }
|
||||
|
||||
/**
|
||||
* The adaptive window: first page, the run around the current one, last page,
|
||||
* with an ellipsis standing in for each hidden stretch. It replaced fixed
|
||||
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
|
||||
* 50-page grid there was no way to reach the end - the primitive wires only
|
||||
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
|
||||
*
|
||||
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
|
||||
* At the default of 5 this emits exactly the same rows as the block version
|
||||
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
|
||||
* An ellipsis never stands for a single page, because the early return covers
|
||||
* every count a full row could hold.
|
||||
*/
|
||||
function getDataGridPaginationItems(
|
||||
pageIndex: number,
|
||||
pageCount: number,
|
||||
limit: number
|
||||
): DataGridPaginationItem[] {
|
||||
// Pages flanking the current one, per side. The run is rebuilt from it so
|
||||
// the count is always odd: an even `limit` would make the head and tail
|
||||
// rows one slot wider than the middle, and the footer would twitch as the
|
||||
// user pages through.
|
||||
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
|
||||
const pages = sibling * 2 + 3
|
||||
|
||||
// Two ellipses cost the width of two pages, so below this there is nothing
|
||||
// to gain by hiding any.
|
||||
if (pageCount <= pages + 2) {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
}))
|
||||
}
|
||||
|
||||
if (pageIndex <= sibling + 2) {
|
||||
return [
|
||||
...Array.from({ length: pages }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pages },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
if (pageIndex >= pageCount - sibling - 3) {
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageCount - pages - 1,
|
||||
},
|
||||
...Array.from({ length: pages }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageCount - pages + offset,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageIndex - sibling - 1,
|
||||
},
|
||||
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageIndex - sibling + offset,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A page button is square at one digit and grows from there, so the row does
|
||||
* not reflow when the count crosses 10 or 100. The floor is each style's own
|
||||
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
|
||||
* and keep the arrows' height. Hardcoding one value would be right in three
|
||||
* styles and wrong in five.
|
||||
*/
|
||||
const PAGE_BUTTON_WIDTH_CLASS =
|
||||
"min-w-7"
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
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",
|
||||
ellipsisText: "...",
|
||||
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||
previousPageLabel: i18n.labels.previousPage,
|
||||
nextPageLabel: i18n.labels.nextPage,
|
||||
ellipsisText: i18n.labels.paginationEllipsis,
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
// A supplied `info` keeps its placeholder-template contract; the default
|
||||
// routes through the i18n label function, where word order is free.
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||
const currentGroupEnd = Math.min(
|
||||
currentGroupStart + paginationMoreLimit,
|
||||
pageCount
|
||||
const paginationItems = getDataGridPaginationItems(
|
||||
pageIndex,
|
||||
pageCount,
|
||||
mergedProps.moreLimit ?? 5
|
||||
)
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = []
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||
the value "100" by 1px at nova's paddings, while fit-content
|
||||
grows the trigger for 3-digit sizes and the min keeps the
|
||||
1-2 digit ones from collapsing narrower than 64px. */}
|
||||
<SelectTrigger
|
||||
aria-label={mergedProps.rowsPerPageLabel}
|
||||
className="w-fit min-w-16"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
{paginationItems.map((item) =>
|
||||
item.type === "page" ? (
|
||||
<Button
|
||||
key={`page-${item.index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.index + 1)}
|
||||
aria-current={
|
||||
pageIndex === item.index ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
PAGE_BUTTON_WIDTH_CLASS,
|
||||
"px-1.5 text-sm",
|
||||
"text-muted-foreground",
|
||||
{
|
||||
"bg-accent text-accent-foreground":
|
||||
pageIndex === item.index,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
if (pageIndex !== item.index) {
|
||||
table.setPageIndex(item.index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index + 1}
|
||||
</Button>
|
||||
) : (
|
||||
/* Clickable, unlike the shadcn PaginationEllipsis, which is
|
||||
an aria-hidden span. This one MOVES the user, so it needs
|
||||
a name saying where. */
|
||||
<Button
|
||||
key={`ellipsis-${item.direction}`}
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.target + 1)}
|
||||
onClick={() => table.setPageIndex(item.target)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
|
||||
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
|
||||
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
|
||||
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
|
||||
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"
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 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-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
@@ -454,12 +457,12 @@ function DataGridScrollArea({
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"bg-border absolute end-px w-1.5",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
||||
function DataGridTableDndRowHandle({
|
||||
className,
|
||||
disabled,
|
||||
disabledLabel = "Reordering unavailable",
|
||||
disabledLabel,
|
||||
}: {
|
||||
className?: string
|
||||
/**
|
||||
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
|
||||
/** Announced and shown on hover in place of the drag affordance. */
|
||||
disabledLabel?: string
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
const context = useContext(SortableRowContext)
|
||||
const resolvedDisabledLabel =
|
||||
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||
|
||||
if (!context || disabled) {
|
||||
return (
|
||||
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
|
||||
disabled && "cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
||||
title={disabled ? disabledLabel : undefined}
|
||||
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
|
||||
title={disabled ? resolvedDisabledLabel : undefined}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label={i18n.labels.dragToReorderRow}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
|
||||
}: {
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { i18n, props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label={i18n.labels.dragToReorder}
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableAddRow,
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
|
||||
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||
|
||||
interface DataGridTableColumnVirtualizerOptions {
|
||||
/** Off unless explicitly true; a column window is opt-in. */
|
||||
enabled?: boolean
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
type DataGridTableColumnScrollRequest = {
|
||||
align: DataGridTableVirtualScrollAlignment
|
||||
behavior: ScrollBehavior
|
||||
columnId: string | undefined
|
||||
columnIndex: number
|
||||
scrollElement: HTMLElement
|
||||
}
|
||||
|
||||
function isSameDataGridTableColumnScrollRequest(
|
||||
previous: DataGridTableColumnScrollRequest | null,
|
||||
next: DataGridTableColumnScrollRequest
|
||||
) {
|
||||
return (
|
||||
previous?.align === next.align &&
|
||||
previous.behavior === next.behavior &&
|
||||
previous.columnId === next.columnId &&
|
||||
previous.columnIndex === next.columnIndex &&
|
||||
previous.scrollElement === next.scrollElement
|
||||
)
|
||||
}
|
||||
|
||||
type DataGridTableVirtualScrollRequest = {
|
||||
align: DataGridTableVirtualScrollAlignment
|
||||
behavior: ScrollBehavior
|
||||
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
|
||||
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
||||
/** Index within the center (non-pinned) row section to reveal. */
|
||||
scrollToRowIndex?: number
|
||||
/**
|
||||
* Opt-in horizontal virtualization of the CENTER columns. Activates only
|
||||
* under a fixed table layout with a single ungrouped header row; pinned
|
||||
* columns stay mounted, and anything else falls back to full-column
|
||||
* rendering. `scrollBehavior` is shared with controlled row scrolling.
|
||||
*/
|
||||
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
|
||||
/** Alignment used when revealing a controlled target column. */
|
||||
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
|
||||
/** Index within the center (non-pinned) visible leaf columns to reveal. */
|
||||
scrollToColumnIndex?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
centerColumnWindow?: { start: number; end: number }
|
||||
}
|
||||
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A scroll frame only shifts the window, so every surviving row's inputs are
|
||||
* identical and its last render is reused; the per-frame cost is the rows
|
||||
* entering the window, not all mounted rows. Cell-level state (selection,
|
||||
* focus, row checks) repaints through each cell's own Subscribe, and any real
|
||||
* data change rebuilds the row wrappers, so identity comparison is safe.
|
||||
*/
|
||||
const MemoizedRenderedRow = memo(
|
||||
DataGridTableRenderedRow,
|
||||
(prev, next) =>
|
||||
prev.row === next.row &&
|
||||
prev.rowIndex === next.rowIndex &&
|
||||
prev.rowRef === next.rowRef &&
|
||||
prev.pinnedBoundary === next.pinnedBoundary &&
|
||||
prev.centerWindow?.start === next.centerWindow?.start &&
|
||||
prev.centerWindow?.end === next.centerWindow?.end
|
||||
) as typeof DataGridTableRenderedRow
|
||||
|
||||
function DataGridTableVirtualBody<TData extends object>({
|
||||
table,
|
||||
topRows,
|
||||
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
centerColumnWindow,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const { isLoading } = useDataGrid()
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
centerWindow={centerColumnWindow}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
rowIndex={virtualRow.index}
|
||||
centerWindow={centerColumnWindow}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
} else {
|
||||
centerRows.forEach((row, rowIndex) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
centerWindow={centerColumnWindow}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
centerWindow={centerColumnWindow}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
* Memoized virtual body: 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.
|
||||
* A cell-selection drag gets the same treatment: painting goes through each
|
||||
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
|
||||
* memo boundary, so parent-driven reconciliation during the drag is waste.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||
(_prev, next) =>
|
||||
!!next.table.state.columnResizing.isResizingColumn ||
|
||||
next.table._isSelectingCells === true
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData extends object>({
|
||||
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
|
||||
scrollBehavior = "auto",
|
||||
scrollToRowAlign = "auto",
|
||||
scrollToRowIndex,
|
||||
columnVirtualizerOptions,
|
||||
scrollToColumnAlign = "auto",
|
||||
scrollToColumnIndex,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const { i18n, table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
// Column windows only where the geometry is provable: a fixed table
|
||||
// layout (undefined defaults to fixed; the colgroup then owns every
|
||||
// width, so colSpan spacers cannot drift) and a single ungrouped header
|
||||
// row (a colSpan group cannot be windowed). Anything else falls back to
|
||||
// full-column rendering silently, the same posture as row virtualization
|
||||
// toward unsupported shapes.
|
||||
const columnVirtualizationActive =
|
||||
columnVirtualizerOptions?.enabled === true &&
|
||||
props.tableLayout?.width !== "auto" &&
|
||||
mergedHeaderGroups.length === 1 &&
|
||||
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
|
||||
false)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
// Horizontal offsets invert in RTL; the virtualizer must be told.
|
||||
const isRtl = useMemo(
|
||||
() =>
|
||||
viewportElements.containerElement
|
||||
? getComputedStyle(viewportElements.containerElement).direction ===
|
||||
"rtl"
|
||||
: false,
|
||||
[viewportElements.containerElement]
|
||||
)
|
||||
|
||||
const resolveColumnKey = useCallback(
|
||||
(index: number) => centerVisibleColumns[index]?.id ?? index,
|
||||
[centerVisibleColumns]
|
||||
)
|
||||
|
||||
const resolveColumnEstimateSize = useCallback(
|
||||
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
|
||||
[centerVisibleColumns]
|
||||
)
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
horizontal: true,
|
||||
isRtl,
|
||||
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveColumnKey,
|
||||
estimateSize: resolveColumnEstimateSize,
|
||||
overscan: columnVirtualizerOptions?.overscan ?? 3,
|
||||
})
|
||||
|
||||
// Column sizes are exact reads of getSize(), never DOM-measured, so a
|
||||
// resize commit or a visibility/order/pinning change must resync the
|
||||
// virtualizer's cached sizes by hand.
|
||||
useEffect(() => {
|
||||
if (columnVirtualizationActive) columnVirtualizer.measure()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnVirtualizationActive,
|
||||
columnVirtualizer,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnSizing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnVisibility,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnOrder,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnPinning,
|
||||
])
|
||||
|
||||
const virtualColumns = columnVirtualizationActive
|
||||
? columnVirtualizer.getVirtualItems()
|
||||
: []
|
||||
// The window is a contiguous inclusive [start, end] over the center
|
||||
// columns; before the scroll element resolves the item list is empty and
|
||||
// the grid renders full-width for that first frame.
|
||||
const centerColumnWindow =
|
||||
columnVirtualizationActive && virtualColumns.length > 0
|
||||
? {
|
||||
start: virtualColumns[0]!.index,
|
||||
end: virtualColumns[virtualColumns.length - 1]!.index,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
|
||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||
// hasMore is never set.
|
||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||
const lastColumnScrollRequestRef =
|
||||
useRef<DataGridTableColumnScrollRequest | null>(null)
|
||||
|
||||
// Controlled column reveal: same dedupe posture as the row request - the
|
||||
// signature keeps ordinary renders from re-scrolling, and exact column
|
||||
// sizes mean no post-measure follow-up pass is needed.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!columnVirtualizationActive ||
|
||||
scrollToColumnIndex === undefined ||
|
||||
scrollToColumnIndex < 0 ||
|
||||
scrollToColumnIndex >= centerVisibleColumns.length ||
|
||||
!viewportElements.scrollElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
const request: DataGridTableColumnScrollRequest = {
|
||||
align: scrollToColumnAlign,
|
||||
behavior: scrollBehavior,
|
||||
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
|
||||
columnIndex: scrollToColumnIndex,
|
||||
scrollElement: viewportElements.scrollElement,
|
||||
}
|
||||
if (
|
||||
isSameDataGridTableColumnScrollRequest(
|
||||
lastColumnScrollRequestRef.current,
|
||||
request
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastColumnScrollRequestRef.current = request
|
||||
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
|
||||
align: scrollToColumnAlign,
|
||||
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
|
||||
})
|
||||
}, [
|
||||
columnVirtualizationActive,
|
||||
columnVirtualizer,
|
||||
centerVisibleColumns,
|
||||
scrollToColumnAlign,
|
||||
scrollToColumnIndex,
|
||||
scrollBehavior,
|
||||
viewportElements.scrollElement,
|
||||
])
|
||||
|
||||
// Resolve after every commit so a stable getter can expose a replaced ref;
|
||||
// the request signature prevents duplicate scrolling on ordinary renders.
|
||||
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: {
|
||||
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>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
// The header re-renders only when its real inputs move: the table
|
||||
// wrapper (any table state change recreates it), the layout props, and
|
||||
// the column window. A scroll frame changes none of them, so the whole
|
||||
// sortable-header subtree is reused instead of rebuilt per frame.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const headerNode = useMemo(
|
||||
() =>
|
||||
renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{/* Under an active column window the single ungrouped header
|
||||
row is bucketed start / windowed center / end, with each
|
||||
off-window flank one colSpan spacer sized by the intact
|
||||
colgroup - the same shape the body rows take. */}
|
||||
{centerColumnWindow
|
||||
? headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "start")
|
||||
.map((header) => (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
header.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
))
|
||||
: null}
|
||||
{centerColumnWindow && centerColumnWindow.start > 0 ? (
|
||||
<th
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-virtual-col-spacer"
|
||||
colSpan={centerColumnWindow.start}
|
||||
className="p-0"
|
||||
/>
|
||||
) : null}
|
||||
{centerColumnWindow
|
||||
? headerGroup.headers
|
||||
.filter((header) => !header.column.getIsPinned())
|
||||
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
|
||||
.map((header) => (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
header.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
))
|
||||
: null}
|
||||
{centerColumnWindow &&
|
||||
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
|
||||
<th
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-virtual-col-spacer"
|
||||
colSpan={
|
||||
centerVisibleColumns.length - 1 - centerColumnWindow.end
|
||||
}
|
||||
className="p-0"
|
||||
/>
|
||||
) : null}
|
||||
{!centerColumnWindow &&
|
||||
headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "end")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
@@ -890,38 +1135,66 @@ function DataGridTableVirtual<TData extends object>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "end")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "end")
|
||||
.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} />
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
renderHeader,
|
||||
table,
|
||||
props.tableLayout,
|
||||
centerColumnWindow,
|
||||
hasRightPinnedColumns,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: {
|
||||
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>
|
||||
{headerNode}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
centerColumnWindow={centerColumnWindow}
|
||||
/>
|
||||
{/* Same appended region as the standard body, so onRowCreate and
|
||||
a consumer draft work in the virtual layout too. */}
|
||||
{props.appendRow}
|
||||
<DataGridTableAddRow />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
mergeDataGridI18n,
|
||||
type DataGridI18nConfig,
|
||||
type DataGridI18nOverrides,
|
||||
} from "@/components/reui/data-grid/data-grid-i18n"
|
||||
import {
|
||||
cellSelectionFeature,
|
||||
columnFacetingFeature,
|
||||
columnFilteringFeature,
|
||||
columnOrderingFeature,
|
||||
@@ -30,6 +43,7 @@ import {
|
||||
tableFeatures,
|
||||
} from "@tanstack/react-table"
|
||||
import type {
|
||||
Cell,
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
ReactTable,
|
||||
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
|
||||
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||
* table in the consuming app.
|
||||
*/
|
||||
/**
|
||||
* Per-column write contract for the spreadsheet operations (paste, fill,
|
||||
* clear, cut). Presence marks the column writable; every write still flows
|
||||
* through `onCellsChange`, so the consumer's state stays the only data owner.
|
||||
*/
|
||||
export interface DataGridColumnCellEdit<TData> {
|
||||
/**
|
||||
* Set false for a column that formats clipboard output but is never
|
||||
* written; a function decides per row, so locked rows (archived, another
|
||||
* user's, a totals row) reject like any read-only cell. Defaults to true.
|
||||
*/
|
||||
editable?: boolean | ((row: TData) => boolean)
|
||||
/**
|
||||
* Clipboard or fill string to typed value. Return undefined to reject the
|
||||
* cell into `rejected`. Without it the raw string passes through unchanged -
|
||||
* a deliberate non-coercion, so a number column receives strings only when
|
||||
* the consumer chose not to parse.
|
||||
*/
|
||||
parse?: (raw: string, row: TData) => unknown
|
||||
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
|
||||
format?: (value: unknown, row: TData) => string
|
||||
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
|
||||
clearValue?: unknown
|
||||
/**
|
||||
* Opt into the grid's built-in free-text editor: an overlay input (or
|
||||
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
|
||||
* typing, or double-click, committing through `onCellsChange` as source
|
||||
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
|
||||
* via `onCellEditRequest` or their own cell renderers.
|
||||
*/
|
||||
control?: "text" | "textarea"
|
||||
}
|
||||
|
||||
export interface DataGridColumnMeta<TData> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
autoSize?: boolean
|
||||
cellEdit?: DataGridColumnCellEdit<TData>
|
||||
/**
|
||||
* Under `columnsResizable`, this column absorbs the free space the filler
|
||||
* strip would otherwise hold, so the grid always reads full-width and the
|
||||
* built-in editor covers the whole cell. One column per grid; while free
|
||||
* space remains, manual resizing of this column is visually a no-op (the
|
||||
* absorbed space compensates), the flex-column trade-off.
|
||||
*/
|
||||
fillWidth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
|
||||
rowSelectionFeature,
|
||||
rowExpandingFeature,
|
||||
rowPinningFeature,
|
||||
// Registration alone is inert: it seeds a `cellSelection: []` slice and
|
||||
// prototype methods but binds no DOM handlers, so grids without
|
||||
// `tableLayout.cellSelection` render and behave exactly as before.
|
||||
cellSelectionFeature,
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
filteredRowModel: createFilteredRowModel(),
|
||||
paginatedRowModel: createPaginatedRowModel(),
|
||||
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The td contract for spreadsheet selection. Call inside a
|
||||
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
|
||||
* builder calls whose state dependency React Compiler cannot see, exactly the
|
||||
* trap the row-selection checkbox documents.
|
||||
*/
|
||||
export function getDataGridCellSelectionCellAttrs<TData extends object>(
|
||||
cell: Cell<DataGridFeatures, TData, unknown>
|
||||
): {
|
||||
"aria-selected": boolean
|
||||
"data-col-id": string
|
||||
"data-cell-selected": true | undefined
|
||||
"data-cell-focused": true | undefined
|
||||
"data-cell-edge-top": true | undefined
|
||||
"data-cell-edge-right": true | undefined
|
||||
"data-cell-edge-bottom": true | undefined
|
||||
"data-cell-edge-left": true | undefined
|
||||
} {
|
||||
const selected = cell.getIsSelected()
|
||||
const edges = cell.getSelectionEdges()
|
||||
return {
|
||||
"aria-selected": selected,
|
||||
"data-col-id": cell.column.id,
|
||||
"data-cell-selected": selected || undefined,
|
||||
"data-cell-focused": cell.getIsFocused() || undefined,
|
||||
"data-cell-edge-top": edges.top || undefined,
|
||||
"data-cell-edge-right": edges.right || undefined,
|
||||
"data-cell-edge-bottom": edges.bottom || undefined,
|
||||
"data-cell-edge-left": edges.left || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection chrome for a body td, activated by the data attributes above plus
|
||||
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
|
||||
* outside React like the resize indicator). A ::before overlay, not outline
|
||||
* or box-shadow: an outline ring hugs the cell box and cannot line up with
|
||||
* the gridlines the perimeter paints on, and the pinned-column dividers
|
||||
* already own the cell's shadow slot (tailwind-merge would collapse a second
|
||||
* shadow-[...] into it).
|
||||
*/
|
||||
export const dataGridCellSelectionCellClasses = cn(
|
||||
"relative select-none",
|
||||
// A light tint: the selection must read as a range without drowning the
|
||||
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
|
||||
// cells must stay OPAQUE (they hide scrolled content), so they get the
|
||||
// tint pre-mixed over the background, and the focused pinned cell keeps
|
||||
// a solid background instead of turning transparent.
|
||||
"data-[cell-selected]:bg-primary/4",
|
||||
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
|
||||
"data-[cell-focused]:bg-transparent!",
|
||||
"data-[cell-focused]:data-pinned:bg-background!",
|
||||
// Every selection line is one layout-free ::before overlay per cell. It
|
||||
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
|
||||
// gridline it replaces: the range perimeter and the interior dividers
|
||||
// share this geometry, so where they meet they coincide instead of
|
||||
// stacking into a wider edge, every side stays exactly 1px, and
|
||||
// selecting never adds real borders that shift layout. At the table's
|
||||
// own boundary there is no shared gridline and the overshoot leaves the
|
||||
// scrollable content box, where it is CLIPPED - the perimeter simply
|
||||
// vanished on the first column, last column and last row. So every
|
||||
// overshoot that can hit a boundary goes through a variable the
|
||||
// boundary cells zero out. Longhand inset utilities only, logical
|
||||
// start/end
|
||||
// so RTL mirrors for free; a separate override rule cannot do this,
|
||||
// equal-specificity variants leave shorthand-vs-longhand to sort order.
|
||||
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
|
||||
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
|
||||
// Interior dividers: selected cells repaint their own end and bottom
|
||||
// gridlines on the overlay, so gridline-less grids still divide a range.
|
||||
// The gray color is guarded by not- variants (higher specificity), so a
|
||||
// side that belongs to the range perimeter deterministically stays
|
||||
// primary.
|
||||
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
|
||||
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
|
||||
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
|
||||
// The Sheets model: a single selected cell IS its own range, so the four
|
||||
// edge attributes below already draw its full primary box; inside a
|
||||
// larger range the anchor reads by its unfilled background alone. The
|
||||
// explicit ring only covers a focused cell with no selection at all.
|
||||
// Edge sides are logical (border-e/border-s): the feature derives edges
|
||||
// from display-order column indexes, which mirror in RTL.
|
||||
"data-[cell-focused]:not-data-[cell-selected]:before:border",
|
||||
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
|
||||
// While a fill drag is live the preview border is the ONE painter for
|
||||
// the whole pending region, so the source cells' own chrome rests -
|
||||
// otherwise its lines double against the region border at every shared
|
||||
// edge. display is the no-cascade-fight switch (see below).
|
||||
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
|
||||
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
|
||||
// While the built-in editor is open its outline is the one border; the
|
||||
// edited cell's own overlay chrome hides so nothing peeks around the
|
||||
// flush editor. display is the switch because no other before: rule
|
||||
// sets it, so there is no cascade order to lose.
|
||||
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
|
||||
// Clamped to the cell edge at the grid's own boundary, the overlay's
|
||||
// lines sit flush against the container border instead of being clipped
|
||||
// away outside the scrollable content. A custom property has no cascade
|
||||
// fight to lose. First/last-child cover the boundary columns: when a
|
||||
// filler td follows the last data column, that cell's overshoot lands
|
||||
// INSIDE the table and needs no clamp, and the td:first/last-child of a
|
||||
// row are its logical start/end in RTL too.
|
||||
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
|
||||
// The top overshoot lands under a sticky header, which is positioned and
|
||||
// paints over it - so the first row's perimeter lost its top line. Clamp
|
||||
// inside the cell there, exactly like the other three boundaries.
|
||||
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
|
||||
"first:[--data-grid-overlay-start:0px]",
|
||||
"last:[--data-grid-overlay-end:0px]",
|
||||
// A pinned column is a boundary like the grid's own edges: the sticky
|
||||
// neighbor would cover an on-gridline overlay line, and re-painting it
|
||||
// from the pinned side doubles into a 2px blur whenever subpixel
|
||||
// positions disagree (browser zoom, fractional fill widths). One
|
||||
// painter instead: the overlay clamps inside its own cell there, the
|
||||
// same custom-property clamp the first and last cells use.
|
||||
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
|
||||
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
|
||||
// Tint only: the dashed region outline is one overlay element drawn by
|
||||
// the fill session, so neighboring target cells never double their edges.
|
||||
"data-[cell-fill-target]:bg-primary/4",
|
||||
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
|
||||
)
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
|
||||
table: DataGridTableInstance<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
|
||||
gridId: string
|
||||
/** The merged label set; components read `i18n.labels.<key>`. */
|
||||
i18n: DataGridI18nConfig
|
||||
/**
|
||||
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||
* so every table variant and viewport instance shares one application state.
|
||||
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
|
||||
autoSize?: DataGridAutoSizeController
|
||||
}
|
||||
|
||||
/**
|
||||
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
|
||||
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
|
||||
* renderers always agree on the same encoding.
|
||||
*/
|
||||
export function toDataGridDomId(value: string): string {
|
||||
return value.replace(/[^\w-]/g, "_")
|
||||
}
|
||||
|
||||
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.
|
||||
* Feeds the controller one viewport measurement: the SIGNED free space
|
||||
* between the scroll container and the table, negative while the table
|
||||
* overflows. The first sight of a `meta.autoSize` column absorbs the
|
||||
* free space into its width; after that, every measurement REFLOWS the
|
||||
* same growth - a wider window widens the column, a narrower one gives
|
||||
* the space back down to the column's `minSize` (or its starting width
|
||||
* when none is set) - so the grid tracks its container live. Commits
|
||||
* are coalesced for performance: sub-pixel deltas are dropped, a
|
||||
* streaming resize gets one leading commit plus one trailing commit at
|
||||
* settle, and a width the user dragged is never touched. Safe to call
|
||||
* from every viewport measurement; returns true when a sizing update
|
||||
* was dispatched.
|
||||
*/
|
||||
apply: (fillWidth: number) => boolean
|
||||
apply: (freeSpace: number) => boolean
|
||||
}
|
||||
|
||||
type DataGridAutoSizeReflowState = {
|
||||
applied: { columnId: string; base: number; grown: number } | null
|
||||
settleTimer: ReturnType<typeof setTimeout> | null
|
||||
pendingFreeSpace: number
|
||||
lastCommitAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
|
||||
* StrictMode double-mounts the tree, so two controller instances serve one
|
||||
* page and only the first would remember what it absorbed - the second
|
||||
* reads the grown width as a user drag and freezes the column. The store
|
||||
* object is the one identity that provably survives that dance (the
|
||||
* absorbed sizing rides inside it), and the WeakMap lets a discarded
|
||||
* table take its bookkeeping with it.
|
||||
*/
|
||||
const dataGridAutoSizeStates = new WeakMap<
|
||||
object,
|
||||
DataGridAutoSizeReflowState
|
||||
>()
|
||||
|
||||
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
|
||||
let state = dataGridAutoSizeStates.get(store)
|
||||
if (!state) {
|
||||
state = {
|
||||
applied: null,
|
||||
settleTimer: null,
|
||||
pendingFreeSpace: 0,
|
||||
lastCommitAt: 0,
|
||||
}
|
||||
dataGridAutoSizeStates.set(store, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function createDataGridAutoSizeController<TData extends object>(
|
||||
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
*/
|
||||
getTable: () => DataGridTableInstance<TData>
|
||||
): DataGridAutoSizeController {
|
||||
let applied: { columnId: string; base: number; grown: number } | null = null
|
||||
// Reflow coalescing: a live window drag streams a measurement per frame,
|
||||
// and every accepted commit re-renders the table. One leading commit
|
||||
// keeps single snaps (a maximize) instant; the rest of the stream parks
|
||||
// its latest value behind one trailing timer, so a continuous drag costs
|
||||
// two commits instead of sixty a second. The trailing timer may fire
|
||||
// after the grid unmounts; it re-reads the table then, and a sizing
|
||||
// write to the consumer's store is inert once nothing renders it.
|
||||
const SETTLE_MS = 150
|
||||
|
||||
const reflow = (freeSpace: number): boolean => {
|
||||
const table = getTable()
|
||||
const state = getDataGridAutoSizeState(table.store)
|
||||
// LIVE state, never the render snapshot: a measurement can run between
|
||||
// our own sizing commit and React's re-render, and the stale snapshot
|
||||
// would hide the width this controller just wrote.
|
||||
const columnSizing =
|
||||
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||
if (!state.applied) return false
|
||||
const autoSizeColumn = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)
|
||||
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
|
||||
return false
|
||||
// A width this coordinator did not write belongs to the user's drag;
|
||||
// reflow stands down until a reset re-arms the column.
|
||||
const currentSize = columnSizing[state.applied.columnId]
|
||||
if (currentSize !== undefined && currentSize !== state.applied.grown) {
|
||||
return false
|
||||
}
|
||||
// The free space as it would measure with our growth removed; the
|
||||
// target re-absorbs exactly that, floored at minSize so a narrow
|
||||
// window hands space back without ever crushing the column (no
|
||||
// explicit minSize floors at the starting width instead).
|
||||
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
|
||||
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
|
||||
const target = Math.max(floor, state.applied.base + freeAtBase)
|
||||
if (Math.abs(target - state.applied.grown) < 1) return false
|
||||
state.applied = { ...state.applied, grown: target }
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[state.applied!.columnId]: target,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
apply(fillWidth: number) {
|
||||
apply(freeSpace: number) {
|
||||
const table = getTable()
|
||||
const columnSizing = table.state.columnSizing
|
||||
const state = getDataGridAutoSizeState(table.store)
|
||||
// Same live read as `reflow`; the snapshot race is what made the
|
||||
// re-arm check clear the bookkeeping right after the first absorb.
|
||||
const columnSizing =
|
||||
table.atoms.columnSizing?.get() ?? table.state.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 (state.applied && columnSizing[state.applied.columnId] === undefined) {
|
||||
state.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) {
|
||||
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
|
||||
state.pendingFreeSpace = freeSpace
|
||||
const now = Date.now()
|
||||
if (now - state.lastCommitAt > SETTLE_MS) {
|
||||
state.lastCommitAt = now
|
||||
return reflow(freeSpace)
|
||||
}
|
||||
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
|
||||
state.settleTimer = setTimeout(() => {
|
||||
state.settleTimer = null
|
||||
state.lastCommitAt = Date.now()
|
||||
reflow(state.pendingFreeSpace)
|
||||
}, SETTLE_MS)
|
||||
return false
|
||||
}
|
||||
|
||||
const fillWidth = Math.max(0, freeSpace)
|
||||
if (fillWidth <= 0) return false
|
||||
|
||||
if (!autoSizeColumn) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
// un-resizable: the drag committed, the next viewport measurement
|
||||
// stamped the fill back on top, and the column snapped to its old width.
|
||||
//
|
||||
// Deliberately keyed on observed state rather than on `applied`, which
|
||||
// Deliberately keyed on observed state rather than on `state.applied`, which
|
||||
// is per-coordinator memory: anything that rebuilds the coordinator
|
||||
// (a remount, a new table store) forgets what it did, and the guard has
|
||||
// to survive that. An explicit reset clears the entry and re-arms the
|
||||
// fill, which is what makes double-click-to-reset still work.
|
||||
const currentSize = columnSizing[autoSizeColumn.id]
|
||||
if (currentSize !== undefined && currentSize !== applied?.grown) {
|
||||
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
// 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
|
||||
state.applied &&
|
||||
columnSizing[state.applied.columnId] === state.applied.grown
|
||||
? state.applied
|
||||
: null
|
||||
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||
const grown = base + fillWidth
|
||||
|
||||
applied = { columnId: autoSizeColumn.id, base, grown }
|
||||
state.applied = { columnId: autoSizeColumn.id, base, grown }
|
||||
table.setColumnSizing((old) => {
|
||||
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||
if (revert && next[revert.columnId] === revert.grown) {
|
||||
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
/** Which spreadsheet operation produced a cell change batch. */
|
||||
export type DataGridCellsChangeSource =
|
||||
| "paste"
|
||||
| "cut"
|
||||
| "clear"
|
||||
| "fill"
|
||||
| "edit"
|
||||
|
||||
export interface DataGridCellChange<TData> {
|
||||
rowId: string
|
||||
columnId: string
|
||||
/** The consumer's row object, so immutable write-back needs no second lookup. */
|
||||
row: TData
|
||||
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
|
||||
previousValue: unknown
|
||||
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** What onCellsCopy receives after the grid writes the clipboard. */
|
||||
export interface DataGridCopyDetails {
|
||||
/** The TSV text that reached the clipboard. */
|
||||
text: string
|
||||
/** The same content as a row-major grid of formatted fields. */
|
||||
grid: string[][]
|
||||
/** True when the write came from a cut, which clears the region next. */
|
||||
cut: boolean
|
||||
}
|
||||
|
||||
/** One resolved positive region, in pre-paginated display indexes. */
|
||||
export interface DataGridCellSelectionBound {
|
||||
minRowIndex: number
|
||||
maxRowIndex: number
|
||||
minColumnIndex: number
|
||||
maxColumnIndex: number
|
||||
}
|
||||
|
||||
/** What onCellSelectionChange receives, resolved from the live selection. */
|
||||
export interface DataGridCellSelectionSnapshot {
|
||||
/** The virtually focused cell, the active range's anchor. Null when clear. */
|
||||
focused: { rowId: string; columnId: string } | null
|
||||
/** Every positive region, geometric order. */
|
||||
bounds: DataGridCellSelectionBound[]
|
||||
/** The most recent region, the one copy and paste act on. */
|
||||
activeBound: DataGridCellSelectionBound | null
|
||||
/**
|
||||
* Cells of the selection the view can show - the count that matches the
|
||||
* painted range and what a batch will touch.
|
||||
*/
|
||||
visibleCellCount: number
|
||||
}
|
||||
|
||||
export interface DataGridCellRejection {
|
||||
rowId: string
|
||||
columnId: string
|
||||
raw: string
|
||||
reason: "readonly" | "invalid"
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
|
||||
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
|
||||
* consumer's state stays the only owner of what the statuses mean.
|
||||
*/
|
||||
export type DataGridRowStatus = "new" | "dirty" | "deleted"
|
||||
|
||||
/**
|
||||
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
|
||||
* the amber tone for "dirty", the destructive tone for "invalid".
|
||||
*/
|
||||
export type DataGridCellStatus = "dirty" | "invalid"
|
||||
|
||||
/**
|
||||
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
|
||||
* a typed printable character (then `initialText` carries it, the
|
||||
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
|
||||
* column is writable via `meta.cellEdit`.
|
||||
*/
|
||||
export interface DataGridCellEditRequest<TData> {
|
||||
rowId: string
|
||||
columnId: string
|
||||
row: TData
|
||||
previousValue: unknown
|
||||
/** The typed character that started the edit; absent for Enter and F2. */
|
||||
initialText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One batch per operation, however many cells it spans: a 10k-cell paste is
|
||||
* one callback, one consumer setState, one table rebuild. The grid never
|
||||
* writes data itself.
|
||||
*/
|
||||
export interface DataGridCellsChangeDetails<TData> {
|
||||
source: DataGridCellsChangeSource
|
||||
changes: DataGridCellChange<TData>[]
|
||||
rejected: DataGridCellRejection[]
|
||||
}
|
||||
|
||||
export interface DataGridProps<
|
||||
TFeatures extends TableFeatures,
|
||||
TData extends object,
|
||||
@@ -300,7 +707,62 @@ export interface DataGridProps<
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
/**
|
||||
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
|
||||
* Served through the props getter like `onRowClick`, so an inline identity
|
||||
* never republishes the context. Absent: copy still works, writes no-op.
|
||||
*/
|
||||
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
|
||||
/**
|
||||
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
|
||||
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
|
||||
*/
|
||||
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
|
||||
/**
|
||||
* Fires on every cell-selection change (focus moves, ranges grow or
|
||||
* clear) with a resolved snapshot, so a selection count, formula bar or
|
||||
* context panel needs no reach into TanStack internals.
|
||||
*/
|
||||
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
|
||||
/**
|
||||
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
|
||||
* native copy and cut events), e.g. to confirm with a toast.
|
||||
*/
|
||||
onCellsCopy?: (details: DataGridCopyDetails) => void
|
||||
/**
|
||||
* Renders an "Add row" affordance as the table body's last row; clicking
|
||||
* it is the consumer's cue to start creating a row. Absent: no row.
|
||||
*/
|
||||
onRowCreate?: () => void
|
||||
/** Label of the onRowCreate affordance. Defaults to "Add row". */
|
||||
rowCreateLabel?: ReactNode
|
||||
/**
|
||||
* Rendered inside the table body after the data rows: the slot a DRAFT
|
||||
* row lives in while it is being created, before it joins `data` and the
|
||||
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
|
||||
*/
|
||||
appendRow?: ReactNode
|
||||
/**
|
||||
* Optional CRUD indication per row. Return undefined for no indication;
|
||||
* omit the prop entirely to render none anywhere.
|
||||
*/
|
||||
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
|
||||
/**
|
||||
* Optional CRUD indication per cell: the classic corner mark on cells the
|
||||
* consumer tracks as edited or invalid.
|
||||
*/
|
||||
getCellStatus?: (
|
||||
row: TData,
|
||||
columnId: string
|
||||
) => DataGridCellStatus | undefined
|
||||
isLoading?: boolean
|
||||
/**
|
||||
* Overrides for every built-in string: menu items, aria labels,
|
||||
* pagination copy, state messages. Merged over the defaults; a more
|
||||
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
|
||||
* pagination label props) still wins over its `i18n` counterpart.
|
||||
*/
|
||||
i18n?: DataGridI18nOverrides
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
@@ -325,6 +787,36 @@ export interface DataGridProps<
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
|
||||
cellSelection?: boolean
|
||||
/**
|
||||
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
|
||||
* grow multi-cell regions. "single": one focused cell only - every
|
||||
* gesture collapses to it and Ctrl/Cmd+A is inert.
|
||||
*/
|
||||
cellSelectionMode?: "single" | "range"
|
||||
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
|
||||
cellFillHandle?: boolean
|
||||
/**
|
||||
* How a cell opens its editor from the mouse: "dblclick" (default,
|
||||
* the spreadsheet standard - first click focuses, second edits) or
|
||||
* "click", opening the editor as soon as a plain click lands on the
|
||||
* focused cell; modifier clicks and drags still select.
|
||||
*/
|
||||
cellEditMode?: "dblclick" | "click"
|
||||
/**
|
||||
* When true, committing an edit with Enter moves focus down (Shift+Enter
|
||||
* up), the Sheets and Excel convention. Off by default: the commit keeps
|
||||
* focus on the edited cell. Tab always commits and moves across.
|
||||
*/
|
||||
cellEditEnterAdvance?: boolean
|
||||
/**
|
||||
* The handle's look: "dot" is a solid primary dot kept inside the
|
||||
* cell corner; "ring" is the Sheets-style hollow circle riding the
|
||||
* corner point, clamped at the grid's own boundary; "square" is the
|
||||
* Excel-style solid square inside the corner. Default "dot".
|
||||
*/
|
||||
cellFillHandleVariant?: "dot" | "ring" | "square"
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
@@ -335,6 +827,20 @@ export interface DataGridProps<
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
/** Override the getRowStatus tints; defaults are the warning-muted set. */
|
||||
rowNew?: string
|
||||
rowDirty?: string
|
||||
rowDeleted?: string
|
||||
/**
|
||||
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
|
||||
* that pins a draft row for quick-create wants it to read as an active
|
||||
* row, not a muted one.
|
||||
*/
|
||||
rowPinned?: string
|
||||
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
|
||||
rowCreate?: string
|
||||
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
|
||||
cellFillHandle?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
|
||||
}) {
|
||||
// Latest-props ref: context reads always resolve fresh props through the
|
||||
// getter below without the memoized context value depending on unstable
|
||||
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
|
||||
const gridId = useId()
|
||||
// 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
|
||||
const i18n = mergeDataGridI18n(props.i18n)
|
||||
const i18nRef = useRef(i18n)
|
||||
i18nRef.current = i18n
|
||||
|
||||
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||
// every state change. Depending on it directly would republish the context
|
||||
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
|
||||
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
||||
}, [table, resizeMode])
|
||||
|
||||
// With the selection UI on, every paste/fill/clear batch replaces `data`,
|
||||
// and the feature's default autoResetCellSelection would wipe the selection
|
||||
// after each write. Ranges are row-id based and unresolvable ids drop out
|
||||
// of the bounds safely, so keeping state across data edits is the correct
|
||||
// default here.
|
||||
const cellSelectionOn = !!props.tableLayout?.cellSelection
|
||||
useEffect(() => {
|
||||
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||
if (table.options.autoResetCellSelection === false) return
|
||||
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
|
||||
}, [table, cellSelectionOn])
|
||||
|
||||
// Rendered row indexes re-base per page, so a range kept across a page flip
|
||||
// re-lights the same positions over different rows. A page change clears
|
||||
// the selection instead - the rule Excel-family grids apply to paginated
|
||||
// range selection.
|
||||
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
|
||||
const previousPageKeyRef = useRef(pageKey)
|
||||
useEffect(() => {
|
||||
if (previousPageKeyRef.current === pageKey) return
|
||||
previousPageKeyRef.current = pageKey
|
||||
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||
tableRef.current.resetCellSelection(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageKey, cellSelectionOn])
|
||||
|
||||
// One autoSize coordinator per table instance so split header/body viewports
|
||||
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
||||
// stable for the life of the table, rather than on `table` itself: the
|
||||
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
|
||||
// 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.
|
||||
// `cellSelection` state is excluded on purpose too: a drag writes it once
|
||||
// per cell crossed, and nothing reads it through context - cells and the
|
||||
// selection bar subscribe to `table.atoms.cellSelection` directly.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
get props() {
|
||||
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
|
||||
get table() {
|
||||
return tableRef.current
|
||||
},
|
||||
get i18n() {
|
||||
return i18nRef.current
|
||||
},
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
gridId,
|
||||
autoSize,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
cellSelection: false,
|
||||
cellFillHandle: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
@@ -555,7 +1101,9 @@ function DataGridContainer({
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
// relative: anchors floating chrome composed inside the container,
|
||||
// like the fill-drag preview outline.
|
||||
className={cn("relative w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { FilterFieldPicker } from "@/components/reui/filters/filters-builder"
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
filterControlSizes,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
|
||||
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
|
||||
@@ -162,6 +162,14 @@ export interface FilterField<V = unknown, O = unknown> {
|
||||
operators?:
|
||||
| FilterOperator[]
|
||||
| ((field: FilterField<V, O>) => FilterOperator[])
|
||||
/**
|
||||
* The operator this field FALLS BACK to, not a way to preselect one. Both
|
||||
* create paths deliberately start a rule on `operator: ""` so the condition
|
||||
* menu opens with nothing chosen, so this is read when an existing rule
|
||||
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
|
||||
* Ignored, with a dev warning, when it names an operator the field does not
|
||||
* offer; the first visible operator wins instead.
|
||||
*/
|
||||
defaultOperator?: string
|
||||
|
||||
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { FiltersAdvanced } from "@/components/reui/filters/filters-advanced"
|
||||
import { FiltersBuilder } from "@/components/reui/filters/filters-builder"
|
||||
|
||||
@@ -3,36 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture for FramePanel theming:
|
||||
*
|
||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||
* border-(--frame-panel-border-color). This means:
|
||||
*
|
||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||
* :not() or !important needed
|
||||
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
||||
// Each rung is the radius that style's own .cn-card resolves through, so a
|
||||
// Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||
// invalid, which also left --frame-radius empty for anything reading it.
|
||||
"[--frame-radius:var(--radius-xl)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||
// Default panel token values — overridden per-variant below
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||
"",
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
// Concentric inner radius: the panel corner nests smoothly inside the frame
|
||||
// corner instead of matching it. The panel sits inset from the frame's outer
|
||||
// edge by the frame's 1px border + --frame-px padding, so its radius is
|
||||
// reduced by that same gap (radius − gap keeps the two arcs parallel). This
|
||||
// base value assumes the bordered default/inverse frame; `ghost` drops the
|
||||
// 1px border term and `dense` pins it back to the frame radius (its panels
|
||||
// are pulled flush to the edge).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
|
||||
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -40,17 +34,12 @@ const frameVariants = cva(
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
// No frame border, so the panel is inset by --frame-px padding only.
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||
},
|
||||
// Header/footer vertical rhythm is tighter than the panel body's, and
|
||||
// the gap widens as the frame grows: the bars read as chrome rather than
|
||||
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
|
||||
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
|
||||
// style-*.css overrides them - so this single ladder drives all shadcn
|
||||
// styles. `px` is deliberately left level with the body so header,
|
||||
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
|
||||
// the practical floor, since anything lower stops reading as padding.
|
||||
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||
// which it stops reading as padding. No style-*.css overrides these.
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||
@@ -72,9 +61,8 @@ const frameVariants = cva(
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
|
||||
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
|
||||
// their corners align with the frame radius rather than nesting inside it.
|
||||
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
@@ -117,13 +105,10 @@ function FramePanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||
// via className overrides these by Tailwind source order - no ! needed.
|
||||
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
@@ -22,12 +23,16 @@ import type {
|
||||
UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
closestCenter,
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
getFirstCollision,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
MouseSensor,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -254,6 +259,49 @@ function Kanban<T>({
|
||||
[columns, columnIds, getItemValue, isColumn]
|
||||
)
|
||||
|
||||
// The droppable under the pointer, not the one the dragged rect overlaps
|
||||
// most: in the gap between columns that overlap flips with every live-preview
|
||||
// move, and each flip re-runs dragOver until React bails out.
|
||||
const lastOverIdRef = useRef<UniqueIdentifier | null>(null)
|
||||
const collisionDetection = useCallback<CollisionDetection>(
|
||||
(args) => {
|
||||
if (isColumn(args.active.id)) {
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter((container) =>
|
||||
isColumn(container.id)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Keyboard drags carry no pointer.
|
||||
if (!args.pointerCoordinates) return rectIntersection(args)
|
||||
|
||||
let overId = getFirstCollision(pointerWithin(args), "id")
|
||||
if (overId != null) {
|
||||
// Over a column's empty space: resolve to its closest item, if any.
|
||||
if (isColumn(overId)) {
|
||||
const itemIds = new Set(columns[overId as string].map(getItemValue))
|
||||
overId =
|
||||
closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container) => itemIds.has(container.id as string)
|
||||
),
|
||||
})[0]?.id ?? overId
|
||||
}
|
||||
lastOverIdRef.current = overId
|
||||
return [{ id: overId }]
|
||||
}
|
||||
|
||||
// Between droppables: hold the last target so the preview stays put.
|
||||
return lastOverIdRef.current != null
|
||||
? [{ id: lastOverIdRef.current }]
|
||||
: rectIntersection(args)
|
||||
},
|
||||
[columns, getItemValue, isColumn]
|
||||
)
|
||||
|
||||
const commitChange = useCallback(
|
||||
(
|
||||
finalValue: Record<string, T[]>,
|
||||
@@ -312,6 +360,7 @@ function Kanban<T>({
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(event.active.id)
|
||||
onDragStart?.(event)
|
||||
|
||||
@@ -421,6 +470,7 @@ function Kanban<T>({
|
||||
}
|
||||
|
||||
dragOriginRef.current = null
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(null)
|
||||
onDragCancel?.(event)
|
||||
},
|
||||
@@ -437,6 +487,7 @@ function Kanban<T>({
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(null)
|
||||
onDragEnd?.(event)
|
||||
|
||||
@@ -589,6 +640,7 @@ function Kanban<T>({
|
||||
modifiers={modifiers}
|
||||
accessibility={accessibility}
|
||||
measuring={MEASURING_CONFIG}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import type { HTMLAttributes, ReactElement } from "react"
|
||||
import {
|
||||
Children,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
@@ -52,12 +52,14 @@ export function ScheduleModulesGrid({
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => <DataGridNameCell icon={Boxes} title={row.original.name} />,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
size: 280,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
@@ -70,6 +72,7 @@ export function ScheduleModulesGrid({
|
||||
</DataGridMonoCell>
|
||||
),
|
||||
meta: { headerTitle: 'Расписание' },
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
@@ -83,6 +86,7 @@ export function ScheduleModulesGrid({
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
size: 190,
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
@@ -92,6 +96,7 @@ export function ScheduleModulesGrid({
|
||||
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
export type SegmentedTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
badgeVariant?: ComponentProps<typeof Badge>['variant']
|
||||
/** @deprecated Счётчик теперь без плашки (линейные вкладки макета). */
|
||||
badgeVariant?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ interface SegmentedTabsProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level section switcher — native shadcn Tabs, default (segmented) list.
|
||||
* Page-level section switcher — line tabs as on the shared mockup boards.
|
||||
* @see https://reui.io/preview/base/components/c-tabs-9
|
||||
* @see https://reui.io/components/tabs
|
||||
*/
|
||||
@@ -47,15 +46,13 @@ export function SegmentedTabs({
|
||||
orientation="horizontal"
|
||||
className={cn('w-full min-w-0 gap-4', className)}
|
||||
>
|
||||
<TabsList className={cn('h-auto w-full min-w-0 justify-start', listClassName)}>
|
||||
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto', listClassName)}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
||||
{item.count}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground font-mono text-xs font-normal">{item.count}</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
@@ -58,7 +58,11 @@ export function SelectMenu<T extends string = string>({
|
||||
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side={side} className={contentClassName}>
|
||||
{/* alignItemWithTrigger=false: список раскрывается ПОД триггером. Режим
|
||||
по умолчанию (true) накладывает выбранный пункт на триггер, а у
|
||||
селектов с плейсхолдером (без значения) попап накрывает сам триггер.
|
||||
Так же делает вендорский data-grid-pagination. */}
|
||||
<SelectContent side={side} alignItemWithTrigger={false} className={contentClassName}>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@evobgp/ui/components/item'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
viewer: 'Просмотр',
|
||||
viewer: 'Наблюдатель',
|
||||
editor: 'Редактор',
|
||||
operator: 'Оператор',
|
||||
node: 'Нода',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export const DATA_GRID_PAGINATION_RU = {
|
||||
sizes: [10, 25, 50] as number[],
|
||||
sizesLabel: 'Показать',
|
||||
sizesDescription: 'на странице',
|
||||
info: '{from}–{to} из {count}',
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
previousPageLabel: 'Предыдущая страница',
|
||||
nextPageLabel: 'Следующая страница',
|
||||
}
|
||||
|
||||
export const DATA_GRID_MESSAGES_RU = {
|
||||
emptyMessage: 'Нет данных',
|
||||
loadingMessage: 'Загрузка…',
|
||||
fetchingMoreMessage: 'Загрузка…',
|
||||
selectAllLabel: 'Выбрать все',
|
||||
selectRowLabel: 'Выбрать строку',
|
||||
pinRowLabel: 'Закрепить строку',
|
||||
unpinRowLabel: 'Открепить строку',
|
||||
allRecordsLoadedMessage: 'Все записи загружены',
|
||||
}
|
||||
@@ -53,6 +53,46 @@ export const FILTERS_LABELS_RU: Partial<FilterLabels> = {
|
||||
noValue: 'нет значения',
|
||||
selectCondition: 'Выберите условие',
|
||||
incomplete: 'незавершённый фильтр',
|
||||
branchAffordance: 'открывает список',
|
||||
exclusiveHint: 'нельзя сочетать с другими вариантами',
|
||||
exclusiveAnnouncement: (label, cleared) =>
|
||||
cleared === 1
|
||||
? `Выбрано «${label}». 1 другой вариант сброшен.`
|
||||
: `Выбрано «${label}». Других вариантов сброшено: ${cleared}.`,
|
||||
itemCount: (count) => `Элементов: ${count}`,
|
||||
fieldsLabel: 'Поля',
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? '1 результат' : `Результатов: ${count}`,
|
||||
actionsLabel: 'Действия',
|
||||
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
|
||||
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
|
||||
groupAnnouncement: (added) => (added ? 'Группа добавлена' : 'Группа удалена'),
|
||||
reorderAnnouncement: (label, position, total) =>
|
||||
`${label} перемещён на позицию ${position} из ${total}`,
|
||||
moveAnnouncement: (label, destination, position, total) =>
|
||||
`${label} перемещён в ${destination}, позиция ${position} из ${total}`,
|
||||
combinatorLabel: (word) => `${word}, изменить связку`,
|
||||
chipMenu: (fieldLabel) => `Параметры фильтра «${fieldLabel}»`,
|
||||
stepAnnouncement: (step, label) => {
|
||||
if (step === 'field') return `Выберите поле. ${label}`
|
||||
if (step === 'operator') return `Выберите условие для ${label}`
|
||||
return `Введите значение для ${label}`
|
||||
},
|
||||
countAnnouncement: (count) =>
|
||||
count === 1 ? 'Применён 1 фильтр' : `Применено фильтров: ${count}`,
|
||||
valueCount: (count) => `Выбрано: ${count}`,
|
||||
valueRange: (from, to) => `${from} – ${to}`,
|
||||
rangeFrom: (fieldLabel) => `${fieldLabel} от`,
|
||||
rangeTo: (fieldLabel) => `${fieldLabel} до`,
|
||||
rangeSeparator: '—',
|
||||
negated: (operatorLabel) => `не ${operatorLabel}`,
|
||||
issueOperator: 'Выберите условие',
|
||||
issueValue: 'Введите значение',
|
||||
issueRange: 'Заполните оба конца диапазона',
|
||||
issueRangeOrder: 'Конец диапазона раньше его начала',
|
||||
issueEmptyGroup: 'В этой группе пока нет условий',
|
||||
issueSummary: (count) =>
|
||||
count === 1 ? '1 строка требует внимания' : `Строк требует внимания: ${count}`,
|
||||
}
|
||||
|
||||
export const FILTERS_OPERATOR_LABELS_RU: FilterOperatorLabels = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ModuleRow, ModuleType } from '@/types/api'
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
|
||||
AS_PREFIXES: { label: 'AS-префиксы', color: 'var(--color-chart-1)' },
|
||||
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
|
||||
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
|
||||
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
DataGridI18nLabels,
|
||||
DataGridI18nOverrides,
|
||||
} from '@/components/reui/data-grid/data-grid-i18n'
|
||||
import type { CascaderLabels } from '@/components/reui/cascader/cascader-types'
|
||||
|
||||
/**
|
||||
* Русские формы множественного числа: 1 элемент, 2 элемента, 5 элементов.
|
||||
*/
|
||||
function ruPlural(count: number, one: string, few: string, many: string): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return one
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few
|
||||
return many
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый русский словарь ReUI DataGrid — официальный i18n-слой (@reui/data-grid-i18n).
|
||||
* Подключается один раз через `i18n={DATA_GRID_I18N_RU}` на <DataGrid>
|
||||
* (FrameDataGrid и ResourcePage) — наследуют все гриды приложения.
|
||||
* Полнота словаря проверяется компилятором: тип DataGridI18nLabels требует все ключи.
|
||||
*
|
||||
* @see https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
const DATA_GRID_LABELS_RU: DataGridI18nLabels = {
|
||||
/* Меню заголовка столбца. */
|
||||
sortAscending: 'По возрастанию',
|
||||
sortDescending: 'По убыванию',
|
||||
pinColumnStart: 'Закрепить слева',
|
||||
pinColumnEnd: 'Закрепить справа',
|
||||
moveColumnStart: 'Сдвинуть влево',
|
||||
moveColumnEnd: 'Сдвинуть вправо',
|
||||
columnsMenu: 'Столбцы',
|
||||
unpinColumn: (title) => `Открепить столбец «${title}»`,
|
||||
toggleColumns: 'Настройка столбцов',
|
||||
/* Афордансы строк и ячеек. */
|
||||
rowCreate: 'Добавить строку',
|
||||
pinRow: 'Закрепить строку',
|
||||
unpinRow: 'Открепить строку',
|
||||
selectRow: 'Выбрать строку',
|
||||
selectAll: 'Выбрать всё',
|
||||
expandRow: 'Развернуть строку',
|
||||
collapseRow: 'Свернуть строку',
|
||||
dragToReorder: 'Перетащите, чтобы изменить порядок',
|
||||
dragToReorderRow: 'Перетащите, чтобы изменить порядок строк',
|
||||
reorderingUnavailable: 'Изменение порядка недоступно',
|
||||
/* Состояния грида. */
|
||||
loading: 'Загрузка…',
|
||||
empty: 'Нет данных',
|
||||
allRowsLoaded: 'Все записи загружены',
|
||||
/* Пагинация. */
|
||||
rowsPerPage: 'Строк на странице',
|
||||
paginationInfo: ({ from, to, count }) => `${from}–${to} из ${count}`,
|
||||
previousPage: 'Предыдущая страница',
|
||||
nextPage: 'Следующая страница',
|
||||
goToPage: (page) => `Перейти на страницу ${page}`,
|
||||
paginationEllipsis: '…',
|
||||
/* Фасетный фильтр столбца. */
|
||||
filterSelectedCount: (count) => `Выбрано: ${count}`,
|
||||
filterNoResults: 'Ничего не найдено.',
|
||||
filterClear: 'Сбросить фильтры',
|
||||
}
|
||||
|
||||
export const DATA_GRID_I18N_RU: DataGridI18nOverrides = {
|
||||
labels: DATA_GRID_LABELS_RU,
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый русский словарь ReUI Cascader (@reui/cascader-i18n).
|
||||
* Передаётся в `labels` каскадера; поверх — контекстный `rootLevel`.
|
||||
*
|
||||
* @see https://reui.io/docs/components/base/cascader
|
||||
*/
|
||||
const CASCADER_ROOT_LEVEL_RU = 'Корневой уровень'
|
||||
|
||||
const cascaderItemCount = (count: number) =>
|
||||
`${count} ${ruPlural(count, 'элемент', 'элемента', 'элементов')}`
|
||||
|
||||
export const CASCADER_LABELS_RU: CascaderLabels = {
|
||||
search: (parentLabel) =>
|
||||
parentLabel ? `Поиск: ${parentLabel}…` : 'Поиск…',
|
||||
back: 'Назад',
|
||||
loading: 'Загрузка…',
|
||||
loadingMore: 'Загрузка…',
|
||||
loadMore: 'Ещё',
|
||||
error: 'Не удалось загрузить элементы.',
|
||||
retry: 'Повторить',
|
||||
empty: 'Ничего не найдено.',
|
||||
selectedCount: (count) => `Выбрано: ${count}`,
|
||||
breadcrumbLabel: 'Навигация',
|
||||
chipsLabel: 'Выбранные элементы',
|
||||
removeChip: (label) => `Удалить «${label}»`,
|
||||
pathSeparator: '/',
|
||||
rootLevel: CASCADER_ROOT_LEVEL_RU,
|
||||
itemCount: cascaderItemCount,
|
||||
branchAffordance: 'подменю',
|
||||
selectedState: 'выбрано',
|
||||
partiallySelectedState: 'частично выбрано',
|
||||
columnsLabel: 'Уровни',
|
||||
actionsLabel: 'Действия',
|
||||
submenuAffordance: 'открывает меню',
|
||||
panelLabel: 'Варианты',
|
||||
keyboardHint: (mode, dir) => {
|
||||
const open = dir === 'rtl' ? 'влево' : 'вправо'
|
||||
const back = dir === 'rtl' ? 'вправо' : 'влево'
|
||||
if (mode === 'tree') {
|
||||
return `Стрелка ${open} — раскрыть уровень, стрелка ${back} — свернуть.`
|
||||
}
|
||||
if (mode === 'columns') {
|
||||
return `Стрелка ${open} — открыть следующий столбец, стрелка ${back} — вернуться.`
|
||||
}
|
||||
return `Стрелка ${open} — открыть ветку, стрелка ${back} — вернуться.`
|
||||
},
|
||||
rootAnnouncement: (count) => `${CASCADER_ROOT_LEVEL_RU}, ${cascaderItemCount(count)}`,
|
||||
expandedAnnouncement: (label, count) => `${label} раскрыт, ${cascaderItemCount(count)}`,
|
||||
collapsedAnnouncement: (label) => `${label} свёрнут`,
|
||||
levelAnnouncement: (parentLabel, depth, count) =>
|
||||
`${parentLabel}, уровень ${depth}, ${cascaderItemCount(count)}`,
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? '1 результат' : `Результатов: ${count}`,
|
||||
maxReachedAnnouncement: (max) => `Достигнут предел выбора: ${max}`,
|
||||
cascadeAnnouncement: (label, count, selecting) =>
|
||||
`${label} ${selecting ? 'выбрано' : 'снято'}, вложенных затронуто: ${count}`,
|
||||
searchingAnnouncement: 'Поиск…',
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefine
|
||||
export function moduleTypeRu(type: string): string {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'AS (номера)'
|
||||
return 'AS-префиксы'
|
||||
case 'CDN_CIDRS':
|
||||
return 'CDN CIDR'
|
||||
case 'DOMAINS':
|
||||
@@ -30,7 +30,7 @@ export function moduleTypeRu(type: string): string {
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
module_refresh: 'Обновление модуля',
|
||||
tenant_refresh: 'Обновление тенанта',
|
||||
tenant_refresh: 'Обновление арендатора',
|
||||
peer_reconcile: 'Согласование пиров',
|
||||
deploy_apply: 'Применение на спикеры',
|
||||
apply: 'Применение конфигурации',
|
||||
@@ -60,7 +60,7 @@ export function jobKindRu(kind: string): string {
|
||||
|
||||
const JOB_TRIGGER_RU: Record<string, string> = {
|
||||
scheduler: 'по расписанию',
|
||||
api: 'вручную',
|
||||
api: 'через API',
|
||||
peer_patch: 'правка пиров',
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ const JOB_STATUS_RU: Record<string, string> = {
|
||||
disabled: 'Выключен',
|
||||
archived: 'В архиве',
|
||||
block: 'Блокировать',
|
||||
accept: 'Принимать',
|
||||
accept: 'Принять',
|
||||
}
|
||||
|
||||
export function jobStatusRu(status: string): string {
|
||||
|
||||
@@ -19,16 +19,6 @@ export const SKIP_TO_CONTENT_CLASS =
|
||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||
export const kpiStatGridClassName = '@container w-full'
|
||||
|
||||
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
|
||||
export const dashboardKpiGridClassName = kpiStatGridClassName
|
||||
|
||||
/** @deprecated Sparkline KPI row replaced by stats-12 grid. */
|
||||
export const kpiGridClassName =
|
||||
'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
|
||||
/** @deprecated */
|
||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||
|
||||
/** Two-column chart panel row (monitoring / network overview). */
|
||||
export const chartPanelGridClassName =
|
||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
|
||||
import { Toaster } from '@evobgp/ui/components/sonner'
|
||||
|
||||
import '@evobgp/ui/globals.css'
|
||||
import './portal-theme.css'
|
||||
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { createRouter } from '@/lib/router'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
|
||||
/* Ячейки таблиц как в MikrotikManager: py-3, поля первой и последней колонки pl-5 / pr-4. */
|
||||
html [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
html [data-slot='data-grid-table'] :is(th, td):first-child {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
html [data-slot='data-grid-table'] :is(th, td):last-child {
|
||||
padding-inline-end: 1rem;
|
||||
}
|
||||
|
||||
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html #main-content *,
|
||||
html [data-slot='sidebar-wrapper'] * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
import { PortalShell } from '@/components/layout/portal-shell'
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: import('@tanstack/react-query').QueryClient
|
||||
@@ -11,8 +11,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PortalShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
</PortalShell>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function DirectoriesComponent() {
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'DoH профили',
|
||||
label: 'DoH-профили',
|
||||
value: dohProfiles.length,
|
||||
icon: <Globe className="size-4" />,
|
||||
footer: (
|
||||
@@ -137,7 +137,7 @@ function DirectoriesComponent() {
|
||||
defaultValue="communities"
|
||||
items={[
|
||||
{ value: 'communities', label: 'BGP community', count: communities.length },
|
||||
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||
{ value: 'doh', label: 'DoH-профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="communities" className="mt-0">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Bird, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
import { MonitoringHealthCard } from '@/components/analytics'
|
||||
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -164,14 +165,17 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
</span>
|
||||
</div>
|
||||
{ratio !== null ? (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
|
||||
}`}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={ratio}
|
||||
aria-label="Установлено BGP-сессий"
|
||||
className={
|
||||
ratio >= 100
|
||||
? '[&_[data-slot=progress-indicator]]:bg-success'
|
||||
: ratio >= 50
|
||||
? '[&_[data-slot=progress-indicator]]:bg-warning'
|
||||
: '[&_[data-slot=progress-indicator]]:bg-destructive'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
|
||||
import {
|
||||
FrameSection,
|
||||
@@ -14,6 +15,14 @@ import {
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
CodeBlockExpandButton,
|
||||
CodeBlockHeader,
|
||||
CodeBlockTitle,
|
||||
} from '@/components/reui/code-block/code-block'
|
||||
import type { CodeBlockDiffSpec } from '@/components/reui/code-block/code-block-highlight'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||
@@ -268,35 +277,31 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
||||
const [b, setB] = useState('')
|
||||
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||
const revisionItems = useMemo(
|
||||
() =>
|
||||
revisions.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.id.slice(0, 12)}…`,
|
||||
})),
|
||||
() => revisions.map((r) => ({ value: r.id, label: r.id })),
|
||||
[revisions],
|
||||
)
|
||||
|
||||
return (
|
||||
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
||||
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 px-5 py-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||
<Field className="w-full max-w-xs">
|
||||
<FieldLabel>Ревизия A</FieldLabel>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={a}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setA(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||
</Field>
|
||||
<Field className="w-full max-w-xs">
|
||||
<FieldLabel>Ревизия B</FieldLabel>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={b}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setB(v)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||
Сравнить
|
||||
</Button>
|
||||
@@ -320,20 +325,49 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
||||
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
||||
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
||||
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
||||
|
||||
if (added.length === 0 && removed.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">Различий между ревизиями нет.</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ReUI CodeBlock diff: добавленные строки идут первыми, удалённые следом,
|
||||
// диапазоны размечают их (+/− канал и зелёный/красный фон строк).
|
||||
const code = [...added, ...removed].join('\n')
|
||||
const diffSpec: CodeBlockDiffSpec = {
|
||||
...(added.length > 0 ? { added: `1-${added.length}` } : {}),
|
||||
...(removed.length > 0
|
||||
? { removed: `${added.length + 1}-${added.length + removed.length}` }
|
||||
: {}),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{added.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{removed.join('\n')}
|
||||
</pre>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="success-light" size="sm">
|
||||
Добавлено: {added.length}
|
||||
</Badge>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
Удалено: {removed.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={code}
|
||||
language="json"
|
||||
showLineNumbers
|
||||
diff={diffSpec}
|
||||
maxLines={24}
|
||||
label="Различия ревизий"
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle className="flex-1">
|
||||
{`${diff.revision_a ?? '—'} → ${diff.revision_b ?? '—'}`}
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockCopyButton />
|
||||
<CodeBlockExpandButton>Показать всё</CodeBlockExpandButton>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/icon-tile.tsx","./src/components/reui/kanban.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/sortable.tsx","./src/components/reui/stepper.tsx","./src/components/reui/timeline.tsx","./src/components/reui/cascader/casLine truncated
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/segmented-tabs.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/status-toggle-group.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/directories/community-create-dialog.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/directories/directories-row-actions.tsx","./src/components/directories/doh-profile-create-dialog.tsx","./src/components/layout/portal-shell.tsx","./src/components/lookup/lookup-add-step.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-status-banner.tsx","./src/components/lookup/lookup-wizard.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-create-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-edit-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/job-timeline.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-jobs-kanban.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/portal/bits.tsx","./src/components/portal/canvas-blocks.tsx","./src/components/portal/chrome-context.tsx","./src/components/portal/chrome-store.ts","./src/components/portal/chrome.tsx","./src/components/portal/data-page-layout.ts","./src/components/portal/data-page.tsx","./src/components/portal/format.ts","./src/components/portal/kpi-strip.tsx","./src/components/portal/kpi.tsx","./src/components/portal/section-grid.tsx","./src/components/portal/tone.ts","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/Line truncated
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function Separator({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
|
||||
@@ -9,37 +9,37 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264.0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264.0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264.0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.520 0.010 264.0);
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--border: oklch(0.904 0.006 264.0);
|
||||
--input: oklch(0.904 0.006 264.0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.546 0.215 264.376);
|
||||
--chart-2: oklch(0.527 0.154 150.069);
|
||||
--chart-3: oklch(0.625 0.162 41.500);
|
||||
--chart-4: oklch(0.591 0.224 296.400);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
--sidebar: oklch(0.152 0.010 264.376);
|
||||
--sidebar-foreground: oklch(0.920 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.240 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.950 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
@@ -51,40 +51,56 @@
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-900);
|
||||
/* Токены MikrotikManager (main) */
|
||||
--chart-line-1: oklch(0.527 0.154 150.069);
|
||||
--chart-line-2: oklch(0.546 0.215 264.376);
|
||||
--chart-line-3: oklch(0.625 0.162 41.500);
|
||||
--chart-line-4: oklch(0.591 0.224 296.400);
|
||||
--chart-rx: oklch(0.527 0.154 150.069);
|
||||
--chart-tx: oklch(0.546 0.215 264.376);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online-fg: oklch(0.437 0.149 150.069);
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
|
||||
--status-offline-fg: oklch(0.505 0.213 27.325);
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
|
||||
--status-degraded-fg: oklch(0.555 0.163 58.318);
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.252 0.010 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.252 0.010 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.637 0.222 264.376);
|
||||
--chart-2: oklch(0.696 0.170 151.328);
|
||||
--chart-3: oklch(0.713 0.176 47.600);
|
||||
--chart-4: oklch(0.714 0.173 303.900);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
@@ -96,6 +112,22 @@
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-600);
|
||||
/* Токены MikrotikManager (main) */
|
||||
--chart-line-1: oklch(0.696 0.170 151.328);
|
||||
--chart-line-2: oklch(0.637 0.222 264.376);
|
||||
--chart-line-3: oklch(0.713 0.176 47.600);
|
||||
--chart-line-4: oklch(0.714 0.173 303.900);
|
||||
--chart-rx: oklch(0.696 0.170 151.328);
|
||||
--chart-tx: oklch(0.637 0.222 264.376);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online-fg: oklch(0.696 0.170 151.328);
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
|
||||
--status-offline-fg: oklch(0.704 0.191 22.216);
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
|
||||
--status-degraded-fg: oklch(0.769 0.149 70.080);
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -130,10 +162,10 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--color-invert-foreground: var(--invert-foreground);
|
||||
--color-invert: var(--invert);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
Generated
+337
@@ -119,6 +119,9 @@ importers:
|
||||
shadcn:
|
||||
specifier: ^4.19.0
|
||||
version: 4.19.0(typescript@5.9.3)
|
||||
shiki:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
sonner:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -1414,6 +1417,37 @@ packages:
|
||||
peerDependencies:
|
||||
semantic-release: '>=20.1.0'
|
||||
|
||||
'@shikijs/core@4.4.3':
|
||||
resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.4.3':
|
||||
resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.3':
|
||||
resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.4.3':
|
||||
resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.4.3':
|
||||
resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.4.3':
|
||||
resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.4.3':
|
||||
resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@simple-libs/stream-utils@1.2.0':
|
||||
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1716,12 +1750,18 @@ packages:
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
|
||||
|
||||
'@types/http-cache-semantics@4.2.0':
|
||||
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/node@20.19.43':
|
||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||
|
||||
@@ -1742,6 +1782,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
@@ -1810,6 +1853,9 @@ packages:
|
||||
resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.4.0':
|
||||
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2078,6 +2124,9 @@ packages:
|
||||
caniuse-lite@1.0.30001800:
|
||||
resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2101,6 +2150,12 @@ packages:
|
||||
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
character-entities-legacy@3.0.0:
|
||||
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -2191,6 +2246,9 @@ packages:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -2445,6 +2503,10 @@ packages:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2452,6 +2514,9 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
diff-sequences@29.6.3:
|
||||
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -2954,6 +3019,12 @@ packages:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
highlight.js@10.7.3:
|
||||
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
|
||||
|
||||
@@ -2973,6 +3044,9 @@ packages:
|
||||
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -3545,6 +3619,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
media-typer@1.1.1:
|
||||
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3568,6 +3645,21 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
|
||||
|
||||
micromark-util-encode@2.0.1:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
|
||||
|
||||
micromark-util-symbol@2.0.1:
|
||||
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
|
||||
|
||||
micromark-util-types@2.0.2:
|
||||
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
@@ -3860,6 +3952,12 @@ packages:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
oniguruma-parser@0.12.2:
|
||||
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4133,6 +4231,9 @@ packages:
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
proto-list@1.2.4:
|
||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||
|
||||
@@ -4332,6 +4433,15 @@ packages:
|
||||
reftools@1.1.9:
|
||||
resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
registry-auth-token@5.1.1:
|
||||
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -4453,6 +4563,10 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@4.4.3:
|
||||
resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
should-equal@2.0.0:
|
||||
resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==}
|
||||
|
||||
@@ -4541,6 +4655,9 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
spawn-error-forwarder@1.0.0:
|
||||
resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==}
|
||||
|
||||
@@ -4594,6 +4711,9 @@ packages:
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
stringify-object@5.0.0:
|
||||
resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -4772,6 +4892,9 @@ packages:
|
||||
resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -4870,6 +4993,21 @@ packages:
|
||||
resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
universal-user-agent@7.0.3:
|
||||
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
|
||||
|
||||
@@ -4972,6 +5110,12 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -5194,6 +5338,9 @@ packages:
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@actions/core@3.0.1':
|
||||
@@ -6469,6 +6616,46 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@shikijs/core@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.4.3
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
|
||||
'@shikijs/primitive@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/themes@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
|
||||
'@shikijs/types@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@simple-libs/stream-utils@1.2.0': {}
|
||||
|
||||
'@sinclair/typebox@0.27.12': {}
|
||||
@@ -6765,10 +6952,18 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/http-cache-semantics@4.2.0': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/node@20.19.43':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -6790,6 +6985,8 @@ snapshots:
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
@@ -6887,6 +7084,8 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.62.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@ungap/structured-clone@1.4.0': {}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -7159,6 +7358,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001800: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -7184,6 +7385,10 @@ snapshots:
|
||||
|
||||
char-regex@1.0.2: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
@@ -7289,6 +7494,8 @@ snapshots:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
@@ -7501,10 +7708,16 @@ snapshots:
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
diff-sequences@29.6.3: {}
|
||||
|
||||
diff@8.0.4: {}
|
||||
@@ -8090,6 +8303,24 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
property-information: 7.2.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
highlight.js@10.7.3: {}
|
||||
|
||||
hono@4.13.3: {}
|
||||
@@ -8104,6 +8335,8 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 11.5.1
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
@@ -8553,6 +8786,18 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-to-hast@13.2.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
'@types/mdast': 4.0.4
|
||||
'@ungap/structured-clone': 1.4.0
|
||||
devlop: 1.1.0
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
trim-lines: 3.0.1
|
||||
unist-util-position: 5.0.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
media-typer@1.1.1: {}
|
||||
|
||||
meow@12.1.1: {}
|
||||
@@ -8565,6 +8810,23 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-util-encode@2.0.1: {}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-symbol@2.0.1: {}
|
||||
|
||||
micromark-util-types@2.0.2: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
@@ -8762,6 +9024,14 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.2
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.1
|
||||
@@ -9014,6 +9284,8 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
@@ -9256,6 +9528,16 @@ snapshots:
|
||||
|
||||
reftools@1.1.9: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
registry-auth-token@5.1.1:
|
||||
dependencies:
|
||||
'@pnpm/npm-conf': 3.0.3
|
||||
@@ -9461,6 +9743,17 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@4.4.3:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.4.3
|
||||
'@shikijs/engine-javascript': 4.4.3
|
||||
'@shikijs/engine-oniguruma': 4.4.3
|
||||
'@shikijs/langs': 4.4.3
|
||||
'@shikijs/themes': 4.4.3
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
should-equal@2.0.0:
|
||||
dependencies:
|
||||
should-type: 1.4.0
|
||||
@@ -9565,6 +9858,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
spawn-error-forwarder@1.0.0: {}
|
||||
|
||||
spdx-correct@3.2.0:
|
||||
@@ -9618,6 +9913,11 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
stringify-entities@4.0.4:
|
||||
dependencies:
|
||||
character-entities-html4: 2.1.0
|
||||
character-entities-legacy: 3.0.0
|
||||
|
||||
stringify-object@5.0.0:
|
||||
dependencies:
|
||||
get-own-enumerable-keys: 1.0.0
|
||||
@@ -9769,6 +10069,8 @@ snapshots:
|
||||
|
||||
traverse@0.6.8: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -9846,6 +10148,29 @@ snapshots:
|
||||
dependencies:
|
||||
crypto-random-string: 4.0.0
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-position@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
@@ -9908,6 +10233,16 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-stringify-position: 4.0.0
|
||||
|
||||
vfile@6.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
@@ -10131,3 +10466,5 @@ snapshots:
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
@@ -140,7 +140,9 @@ if ([string]::IsNullOrWhiteSpace($stagedRaw)) {
|
||||
$stagedFiles = @(
|
||||
$stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
|
||||
)
|
||||
$stagedFiles = $stagedFiles | Sort-Object -Unique
|
||||
$stagedFiles = @(
|
||||
$stagedFiles | Sort-Object -Unique
|
||||
)
|
||||
|
||||
$scopeBuckets = @{}
|
||||
foreach ($f in $stagedFiles) {
|
||||
|
||||
Reference in new issue
Block a user