From 778c42bad88e6a87f064ec7ab4d06baa9cde9cf7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 17 Aug 2026 15:36:55 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=D1=82=D1=8C=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подключить SystemMonitor, IconTile, QuickActionGrid на /apps и skip-link. Co-authored-by: Cursor --- .cursor/rules/kpi-hybrid.mdc | 19 +- .cursor/rules/reui-mcp.mdc | 4 +- AGENTS.md | 4 +- apps/web/src/components/empty-state.tsx | 82 ++++++ apps/web/src/components/layout/app-shell.tsx | 12 +- .../web/src/components/layout/site-header.tsx | 11 +- .../layout/system-monitor-popover.tsx | 243 ++++++++++++++++++ apps/web/src/components/reui-kit/index.ts | 1 - .../src/components/reui-kit/kpi-stat-grid.tsx | 17 +- .../components/reui-kit/quick-action-grid.tsx | 65 +++-- .../src/components/reui-kit/resource-page.tsx | 7 +- apps/web/src/components/reui/icon-stack.tsx | 90 +++++++ apps/web/src/components/reui/icon-tile.tsx | 125 +++++++++ apps/web/src/routes/_auth.apps.tsx | 84 +++--- docs/ui-design-contract.md | 8 +- packages/ui/src/components/sheet.tsx | 5 +- packages/ui/src/components/sidebar.tsx | 19 +- 17 files changed, 687 insertions(+), 109 deletions(-) create mode 100644 apps/web/src/components/empty-state.tsx create mode 100644 apps/web/src/components/layout/system-monitor-popover.tsx create mode 100644 apps/web/src/components/reui/icon-stack.tsx create mode 100644 apps/web/src/components/reui/icon-tile.tsx diff --git a/.cursor/rules/kpi-hybrid.mdc b/.cursor/rules/kpi-hybrid.mdc index cd111f8..2bf203c 100644 --- a/.cursor/rules/kpi-hybrid.mdc +++ b/.cursor/rules/kpi-hybrid.mdc @@ -1,11 +1,11 @@ --- -description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll. +description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll. alwaysApply: true --- -# KPI hybrid — только kit (stats-12 DNA) +# KPI hybrid — только kit (stats-12 + IconTile) -Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`. +Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`. Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). @@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma | Зона | Компонент / DNA | |------|-----------------| | KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) | -| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` | -| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` | -| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) | +| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` | +| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated | +| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) | -Импорты UI: `@authportal/ui/components/*`. +Импорты UI: `@authportal/ui/components/*`. IconTile: `@/components/reui/icon-tile`. ## NEVER - SectionCards / vertical-only KPI / hand-roll Frame/Card KPI -- Другой size / radius / solid brand fill вместо `bg-muted` +- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile +- Другой size / radius / solid brand fill вместо `elevated` - `card-35` как замена stats-12 hybrid KPI - Копипаст ReUI block в route — adapt через `reui-kit/` -- Голый lucide `size-4` в name-cell без hybrid tile +- Голый lucide `size-4` в name-cell без IconTile diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc index 6845166..11703fc 100644 --- a/.cursor/rules/reui-mcp.mdc +++ b/.cursor/rules/reui-mcp.mdc @@ -75,9 +75,9 @@ alwaysApply: true ## Установленные ReUI (apps/web) -**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack` +**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `timeline`, `icon-tile`, `icon-stack` -**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `SettingsShell` +**Kit:** `KpiStatGrid`, `QuickActionGrid`, `AdminUsersGrid` (lists = PageShell + Frame + DataGrid; `ResourcePage` не list-kit) **Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, … diff --git a/AGENTS.md b/AGENTS.md index 044f950..60d5574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,8 @@ - Skill: `.claude/skills/reui` (`668fb463eb`, 20 free components) - Rules: `.cursor/rules/reui-mcp.mdc`, `frontend-shadcn.mdc`, `frontend-ui-patterns.mdc`, `shadcn-mcp.mdc`, `shadcn-ui-production.mdc` - Primary MCP: `user-reui`; primitives: `plugin-shadcn-shadcn` -- Chrome: NavUser in sidebar footer (theme segmented); no ModeToggle in header -- Kit: `KpiStatGrid`, `QuickActionGrid` (SoT markup = EvoBGP); lists = PageShell + Frame + DataGrid +- Chrome: header AppsMenu → SystemMonitorPopover; NavUser in sidebar footer (theme segmented); no ModeToggle in header +- Kit: `KpiStatGrid`, `QuickActionGrid` (SoT markup = EvoBGP + IconTile); lists = `AdminUsersGrid` / PageShell + Frame + DataGrid ## License diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx new file mode 100644 index 0000000..a311d5b --- /dev/null +++ b/apps/web/src/components/empty-state.tsx @@ -0,0 +1,82 @@ +/** + * ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12). + * Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 + * Docs: https://reui.io/docs/components/base/icon-stack + */ +import { InboxIcon, type LucideIcon } from 'lucide-react' +import { IconStack } from '@/components/reui/icon-stack' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@authportal/ui/components/empty' +import { cn } from '@authportal/ui/lib/utils' +import type { ReactNode } from 'react' + +interface EmptyStateProps { + icon?: LucideIcon + title: string + description?: string + action?: ReactNode + className?: string + stackedIcon?: boolean + centered?: boolean +} + +export function EmptyState({ + icon: Icon = InboxIcon, + title, + description, + action, + className, + stackedIcon = true, + centered = true, +}: EmptyStateProps) { + const body = ( + + + + {stackedIcon ? ( + + ) : ( + + + )} + +
+ {title} + {description ? ( + {description} + ) : null} +
+
+ {action ? ( + {action} + ) : null} +
+ ) + + if (!centered) return body + + return ( +
+ {body} +
+ ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index b297737..8affc4c 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -4,6 +4,9 @@ import { SiteHeader } from '@/components/layout/site-header' import { TooltipProvider } from '@authportal/ui/components/tooltip' import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar' +const SKIP_TO_CONTENT_CLASS = + 'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2' + /** Shared ops chrome — etalon app-shell-12. */ export function AppShell({ children }: { children: ReactNode }) { return ( @@ -15,12 +18,15 @@ export function AppShell({ children }: { children: ReactNode }) { } as CSSProperties } > + + К содержимому + - + -
+
{children} -
+
diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index c6e0334..a467e8e 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -11,6 +11,7 @@ import { import { Separator } from '@authportal/ui/components/separator' import { SidebarTrigger } from '@authportal/ui/components/sidebar' import { AppsMenu } from '@/components/layout/apps-menu' +import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' function breadcrumbs(pathname: string) { if (pathname.startsWith('/admin/apps')) { @@ -45,9 +46,12 @@ export function SiteHeader() { const crumbs = useMemo(() => breadcrumbs(pathname), [pathname]) return ( -
+
- + {crumbs.map((crumb, i) => { @@ -70,8 +74,9 @@ export function SiteHeader() { -
+
+
) diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx new file mode 100644 index 0000000..c3d9c8d --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -0,0 +1,243 @@ +import { useMemo, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Activity, AppWindow, KeyRound, Users } from 'lucide-react' + +import { Badge } from '@/components/reui/badge' +import { cn } from '@authportal/ui/lib/utils' +import { Item, ItemMedia } from '@authportal/ui/components/item' +import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover' + +import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth' +import { oidcClientsQueryOptions } from '@/queries/oidc' + +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 MetricBar({ percent, color }: { percent: number; color: string }) { + return ( +
+
+
+ ) +} + +function MetricCell({ metric }: { metric: MonitorMetric }) { + const color = toneColor(metric.tone) + return ( +
+
+
+ + + {metric.icon} + + + {metric.label} +
+ + {metric.value} + {metric.unit} + +
+ +
+ ) +} + +/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */ +export function SystemMonitorPopover() { + const meQ = useQuery(meQueryOptions) + const isAdmin = Boolean(meQ.data?.is_admin) + + const readyQ = useQuery({ + queryKey: ['ready', 'monitor'], + queryFn: async () => { + const res = await fetch('/ready') + if (!res.ok) throw new Error('not ready') + return (await res.json()) as { ok?: boolean } + }, + refetchInterval: 30_000, + retry: false, + }) + const catalogQ = useQuery({ ...catalogQueryOptions, refetchInterval: 30_000 }) + const usersQ = useQuery({ + ...usersQueryOptions, + enabled: isAdmin, + retry: false, + refetchInterval: 30_000, + }) + const oidcQ = useQuery({ + ...oidcClientsQueryOptions, + enabled: isAdmin, + retry: false, + refetchInterval: 30_000, + }) + + const apiOk = readyQ.data?.ok === true || readyQ.isSuccess + const appsCount = catalogQ.data?.apps.length ?? 0 + const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null + const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null + const disabledUsers = isAdmin + ? (usersQ.data ?? []).filter((u) => u.disabled).length + : 0 + + const metrics = useMemo( + () => [ + { + id: 'ready', + label: 'API', + value: apiOk ? 'OK' : '—', + unit: '', + percent: apiOk ? 100 : 15, + icon: , + tone: apiOk ? 'success' : 'destructive', + alert: !apiOk, + }, + { + id: 'apps', + label: 'Приложения', + value: String(appsCount), + unit: 'шт.', + percent: Math.min(100, appsCount * 20), + icon: , + tone: appsCount > 0 ? 'success' : 'warning', + alert: appsCount === 0, + }, + { + id: 'users', + label: 'Пользователи', + value: usersCount == null ? '—' : String(usersCount), + unit: usersCount == null ? '' : 'шт.', + percent: + usersCount == null ? 0 : Math.min(100, Math.max(20, usersCount * 10)), + icon: , + tone: + usersCount == null + ? 'info' + : disabledUsers > 0 + ? 'warning' + : 'success', + alert: disabledUsers > 0, + }, + { + id: 'oidc', + label: 'OIDC', + value: oidcCount == null ? '—' : String(oidcCount), + unit: oidcCount == null ? '' : 'шт.', + percent: oidcCount == null ? 0 : Math.min(100, Math.max(15, oidcCount * 25)), + icon: , + tone: oidcCount == null ? 'info' : oidcCount > 0 ? 'success' : 'warning', + alert: false, + }, + ], + [apiOk, appsCount, disabledUsers, oidcCount, usersCount], + ) + + const spiking = metrics.some((m) => m.alert) || !apiOk + + return ( + + + } + > + + + {spiking ? ( + + ) : null} + + Система + + {spiking ? 'Внимание' : 'Норма'} + + + + +
+ Монитор Auth Portal + + {new Date().toLocaleTimeString('ru-RU')} + +
+
+ {metrics.map((metric, i) => ( +
= 2 && 'border-border border-t', + )} + > + +
+ ))} +
+
+ API:{' '} + {apiOk ? 'OK' : '—'} + {isAdmin ? ( + <> + {' · '} + Отключено:{' '} + {disabledUsers} + + ) : ( + <> + {' · '} + Метрики пользователей — для админа + + )} +
+
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 0c49ed7..a4d0cf6 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -10,7 +10,6 @@ export { type OpsKpiCard, } from './kpi-stat-grid' export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' -export type { ResourcePageProps, ResourcePageTab } from './resource-page' export { AppSwitcherAdminEditor } from './app-switcher-admin-editor' export { UserAccessSheet } from './user-access-sheet' export { UserAuditSheet } from './user-audit-sheet' diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index 6194e86..47bc792 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame' import { Badge } from '@/components/reui/badge' import { cn } from '@authportal/ui/lib/utils' import { kpiCols } from './kpi-cols' -import { Item, ItemMedia } from '@authportal/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { Skeleton } from '@authportal/ui/components/skeleton' export type KpiStatVariant = 'default' | 'warning' | 'destructive' @@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) { return (
{item.icon ? ( - + {item.icon} + ) : null}
diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx index ff02e59..0719814 100644 --- a/apps/web/src/components/reui-kit/quick-action-grid.tsx +++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx @@ -8,7 +8,7 @@ import { FrameTitle, } from '@/components/reui/frame' import { Badge } from '@/components/reui/badge' -import { Item, ItemMedia } from '@authportal/ui/components/item' +import { IconTile } from '@/components/reui/icon-tile' import { cn } from '@authportal/ui/lib/utils' import { kpiCols } from './kpi-cols' @@ -16,8 +16,10 @@ export interface QuickActionItem { id: string title: string description: string - to: string + to?: string search?: Record + onSelect?: () => void + badgeLabel?: string icon?: ReactNode iconClassName?: string } @@ -35,23 +37,20 @@ function QuickActionBody({ action }: { action: QuickActionItem }) { return (
{action.icon ? ( - + {action.icon} + ) : null}
{action.title} - Перейти + {action.badgeLabel ?? 'Перейти'}

@@ -62,9 +61,40 @@ function QuickActionBody({ action }: { action: QuickActionItem }) { ) } +function QuickActionHit({ action }: { action: QuickActionItem }) { + const label = `${action.title}: ${action.description}` + const body = + + if (action.onSelect) { + return ( + + ) + } + + if (!action.to) return body + + return ( + + {body} + + ) +} + /** * KPI-like quick actions strip (horizontal Frame tiles). - * Preview: https://reui.io/preview/base/stats-12 + * Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 */ export function QuickActionGrid({ actions, @@ -88,14 +118,7 @@ export function QuickActionGrid({ key={action.id} className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2" > - - - + ))}

diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 24643bf..d3a6d50 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -1,7 +1,8 @@ /** - * Portal lists compose PageShell + Frame + DataGrid directly. - * Types kept for kit parity with ops apps (CFDM / VPS / EvoBGP). - * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * Portal lists are not ResourcePage. + * Users: `AdminUsersGrid` — DNA [solution-users-1](https://reui.io/preview/base/solution-users-1). + * Other admin tables: PageShell + Frame + DataGrid in the route. + * Types kept for kit parity with ops apps — do not add a ResourcePage component here. */ export type ResourcePageTab = { id: string diff --git a/apps/web/src/components/reui/icon-stack.tsx b/apps/web/src/components/reui/icon-stack.tsx new file mode 100644 index 0000000..a879d0a --- /dev/null +++ b/apps/web/src/components/reui/icon-stack.tsx @@ -0,0 +1,90 @@ +import { cn } from "@authportal/ui/lib/utils" + +type IconStackProps = React.ComponentProps<"div"> + +function IconStack({ className, children, style, ...props }: IconStackProps) { + return ( +
+ + + {children ? ( +
+ {children} +
+ ) : null} +
+ ) +} + +function IconStackLayer({ + active = false, + opacity, + x = 0, + y = 0, +}: { + active?: boolean + opacity: string + x?: number + y?: number +}) { + return ( + + + + + ) +} + +export { IconStack, type IconStackProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/icon-tile.tsx b/apps/web/src/components/reui/icon-tile.tsx new file mode 100644 index 0000000..3989ac1 --- /dev/null +++ b/apps/web/src/components/reui/icon-tile.tsx @@ -0,0 +1,125 @@ +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 "@authportal/ui/lib/utils" + +/** + * CSS variable architecture: + * + * The root owns four variables so every part of the tile stays in proportion + * and stays overridable from a single `className`: + * + * --icon-tile-size tile width/height + * --icon-tile-icon-size glyph size applied to child svgs + * --icon-tile-radius corner radius (also drives the nested inner card) + * --icon-tile-inset gap between the outer ring and the inner card + * + * The `frame` and `soft` variants paint their inner card with an `::after` + * pseudo element instead of a wrapper node. `isolate` makes the root a stacking + * context, so the negative z-index pseudo paints above the root background but + * below the in-flow icon - no extra DOM, and `render` composition keeps working. + * + * Tone: `soft` and `solid` derive every fill and border from `currentColor`, so + * a single text color class (e.g. `text-success`) retints the whole tile. They + * default to `text-primary`; override it to recolor without touching internals. + */ +const iconTileVariants = cva( + [ + "relative inline-flex shrink-0 items-center justify-center align-middle", + "size-(--icon-tile-size) rounded-(--icon-tile-radius)", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)", + ], + { + variants: { + variant: { + /** Plain bordered surface. The quiet default for list rows and toolbars. */ + outline: "border border-border bg-background dark:bg-input/32", + /** Raised muted fill with a background-colored ring. Reads as a physical chip. */ + elevated: + "border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border", + /** + * Tinted double container: an opacity-filled outer ring with no border + * around a bordered inner card, all derived from `currentColor`. The + * quiet, colorful sibling of `frame`. Retint with a text color class. + */ + soft: [ + "isolate p-(--icon-tile-inset) text-primary bg-current/10", + "after:absolute after:-z-10 after:inset-(--icon-tile-inset)", + "after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]", + "after:border after:border-current/20 after:bg-current/5", + ], + /** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */ + solid: "bg-primary text-primary-foreground", + /** Double container - a muted ring around an inset card, matching Frame. */ + frame: [ + "isolate border border-border bg-muted/50 p-(--icon-tile-inset)", + "after:absolute after:-z-10 after:inset-(--icon-tile-inset)", + "after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]", + "after:border after:border-border after:bg-card after:shadow-xs", + ], + }, + size: { + xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]", + sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]", + default: + "[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]", + lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]", + xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]", + }, + /** + * `default`: active style radius. `full`: circular. + * The plain value is the fallback outside a style scope; the `style-*` + * tokens win by specificity inside one, and survive the registry + * transform as the single resolved value per generated style. + * + * Each style token is clamped to a fraction of the tile. A flat radius + * is a circle once it reaches half the box, so the soft styles used to + * render `xs` (24px) and `sm` (32px) as plain circles and swallow the + * `full` variant's meaning. Clamping keeps one corner ratio at every + * size instead, so a tile still reads as the same shape when it scales. + * Luma and Rhea take the gentler quarter ratio; Maia stays at a third. + */ + radius: { + default: + "[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]", + full: "[--icon-tile-radius:calc(infinity*1px)]", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + radius: "default", + }, + } +) + +interface IconTileProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"] + size?: VariantProps["size"] + radius?: VariantProps["radius"] +} + +function IconTile({ + className, + variant = "outline", + size = "default", + radius = "default", + render, + ...props +}: IconTileProps) { + const defaultProps = { + "data-slot": "icon-tile", + "data-variant": variant, + "data-size": size, + className: cn(iconTileVariants({ variant, size, radius, className })), + } + + return useRender({ + defaultTagName: "span", + render, + props: mergeProps<"span">(defaultProps, props), + }) +} + +export { IconTile, iconTileVariants, type IconTileProps } \ No newline at end of file diff --git a/apps/web/src/routes/_auth.apps.tsx b/apps/web/src/routes/_auth.apps.tsx index 3925e06..b4eec36 100644 --- a/apps/web/src/routes/_auth.apps.tsx +++ b/apps/web/src/routes/_auth.apps.tsx @@ -1,17 +1,20 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { LayoutGridIcon } from 'lucide-react' +import { + CloudIcon, + GlobeIcon, + LayoutGridIcon, + ServerIcon, + ShieldIcon, +} from 'lucide-react' import type { AppAuthMode, AppId } from '@authportal/shared' import { PageShell } from '@/components/page-shell' -import { Badge } from '@/components/reui/badge' +import { IconTile } from '@/components/reui/icon-tile' import { Frame, - FrameDescription, - FrameFooter, - FrameHeader, FramePanel, - FrameTitle, } from '@/components/reui/frame' +import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' import { Button } from '@authportal/ui/components/button' import { Skeleton } from '@authportal/ui/components/skeleton' import { ssoOpenApp } from '@/lib/auth' @@ -21,6 +24,14 @@ export const Route = createFileRoute('/_auth/apps')({ component: AppsPage, }) +const APP_ICONS: Record = { + cfdm: CloudIcon, + vps: ServerIcon, + bgp: GlobeIcon, + fw: ShieldIcon, + dns: GlobeIcon, +} + async function openApp( _appId: AppId, baseUrl: string, @@ -66,15 +77,19 @@ function AppsPage() { {isLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( - + ))}
) : apps.length === 0 ? ( -
- -
+

Нет доступных приложений

@@ -84,36 +99,25 @@ function AppsPage() { ) : ( -

- {apps.map((app) => ( - - -
- {app.title} - {app.description} -
- - {app.id} - -
- - - - - ))} -
+ { + const Icon = APP_ICONS[app.id] ?? LayoutGridIcon + return { + id: app.id, + title: app.title, + description: app.description, + badgeLabel: 'Открыть', + icon: , + onSelect: () => + void openApp( + app.id, + app.url, + app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'), + ), + } + })} + /> )} ) diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index a1ffa41..111b432 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -5,7 +5,7 @@ Surface: **ReUI Frame**. Иерархия: **ReUI PRO > shadcn primitives**. Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Registry](https://reui.io/docs/registry) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) -Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12) +Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-14](https://reui.io/preview/base/empty-state-14) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12) ## Surface @@ -15,7 +15,7 @@ Project lock: **`surface: frame`**. Ops / list / settings — только Frame | Component | Role | |-----------|------| -| `ResourcePage` | Frame + Filters + DataGrid (admin lists) | +| `AdminUsersGrid` | Frame + Filters + DataGrid (admin users, solution-users-1) | | `KpiStatGrid` | EvoBGP hybrid KPI (если нужен dashboard) | | `QuickActionGrid` | Frame quick actions (sibling KPI) | | `SettingsShell` | settings nav | @@ -32,7 +32,7 @@ Markup KPI/QuickActions — SoT EvoBGP (diff только `@authportal/ui`). | Sidebar / hover | theme `--sidebar` / `--sidebar-accent` — без `color-mix` override | | Header | `h-12`, sticky, `border-b`, `px-4 md:px-6` | | Header left | `SidebarTrigger` + `Separator` + Breadcrumb | -| Header right | **AppsMenu** (тема — в NavUser) | +| Header right | **AppsMenu → SystemMonitorPopover** (тема — в NavUser) | | Sidebar footer | **NavUser** — аватар, имя, email, тема (segmented), выход · [app-shell-1](https://reui.io/preview/base/app-shell-1) | | `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` | @@ -43,7 +43,7 @@ Nav groups Auth Portal: - **Портал:** Приложения (`/apps`) - **Админ** (только `is_admin`): Пользователи (`/admin`), Журнал (`/admin/audit`), Ссылки приложений (`/admin/apps`) -App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. +App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. ## MCP workflow diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index 07d2602..96ebaf8 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -68,9 +68,8 @@ function SheetContent({ /> } > - - Close +