Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04b27c215 |
@@ -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
|
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), [`web-shadcn.mdc`](web-shadcn.mdc).
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||||
|
|
||||||
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
|
|||||||
| Зона | Компонент / DNA |
|
| Зона | Компонент / DNA |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
| 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` |
|
| 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-*` на `bg-muted` |
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
|
||||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||||
|
|
||||||
Импорты UI: `@evobgp/ui/components/*`.
|
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||||
|
|
||||||
## NEVER
|
## NEVER
|
||||||
|
|
||||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
- 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
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
- Голый lucide `size-4` в name-cell без IconTile
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Установленные ReUI (apps/web)
|
## Установленные 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`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
|
||||||
|
|
||||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
**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`, …
|
**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`, …
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ REUI_LICENSE_KEY=
|
|||||||
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||||
- Смешивать Card и Frame на ops-экране
|
- Смешивать Card и Frame на ops-экране
|
||||||
- Ставить shadcn/ui blocks выше ReUI PRO
|
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||||
|
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
|
||||||
|
|
||||||
## Чеклист
|
## Чеклист
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
|
|||||||
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||||
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||||
|
|
||||||
|
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
|
||||||
|
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation Sync (Web)
|
## Documentation Sync (Web)
|
||||||
|
|||||||
@@ -1,62 +1,2 @@
|
|||||||
import { type ReactNode } from "react"
|
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
||||||
|
export { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldTitle,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
|
|
||||||
interface SettingRowProps {
|
|
||||||
title: string
|
|
||||||
description?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
last?: boolean
|
|
||||||
labelFor?: string
|
|
||||||
contentClassName?: string
|
|
||||||
titleAddon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRow({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
last,
|
|
||||||
labelFor,
|
|
||||||
contentClassName,
|
|
||||||
titleAddon,
|
|
||||||
}: SettingRowProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{labelFor ? (
|
|
||||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
|
||||||
) : (
|
|
||||||
<FieldTitle>{title}</FieldTitle>
|
|
||||||
)}
|
|
||||||
{titleAddon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{description ? (
|
|
||||||
<FieldDescription>{description}</FieldDescription>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldContent
|
|
||||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{!last ? <FieldSeparator /> : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +1,2 @@
|
|||||||
import { type ReactNode } from 'react'
|
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
||||||
|
export { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
interface SettingsCardProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
children: ReactNode
|
|
||||||
footer?: ReactNode
|
|
||||||
className?: string
|
|
||||||
contentClassName?: string
|
|
||||||
footerClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
|
||||||
export function SettingsCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
className,
|
|
||||||
contentClassName,
|
|
||||||
footerClassName,
|
|
||||||
}: SettingsCardProps) {
|
|
||||||
return (
|
|
||||||
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
|
||||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
|
||||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
|
||||||
<FrameTitle>{title}</FrameTitle>
|
|
||||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
|
||||||
</FrameHeader>
|
|
||||||
|
|
||||||
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
|
||||||
|
|
||||||
{footer ? (
|
|
||||||
<FrameFooter
|
|
||||||
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</FrameFooter>
|
|
||||||
) : null}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +1,2 @@
|
|||||||
import { type ReactNode } from "react"
|
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
||||||
|
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
} from "@evobgp/ui/components/field"
|
|
||||||
|
|
||||||
interface SettingsFieldGroupProps {
|
|
||||||
legend: string
|
|
||||||
description: string
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
fieldGroupClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsFieldGroup({
|
|
||||||
legend,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
fieldGroupClassName,
|
|
||||||
}: SettingsFieldGroupProps) {
|
|
||||||
return (
|
|
||||||
<FieldSet className={cn("gap-0", className)}>
|
|
||||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
|
||||||
{/* Description */}
|
|
||||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
|
||||||
{/* List */}
|
|
||||||
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
|
||||||
{children}
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
import { useState, type ReactElement, type ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from '@evobgp/ui/components/drawer'
|
|
||||||
import type { ReactElement, ReactNode } from 'react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
confirmDrawerContentClassName,
|
AlertDialog,
|
||||||
DrawerActionsFooter,
|
AlertDialogAction,
|
||||||
} from '@/components/drawer-layout'
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@evobgp/ui/components/alert-dialog'
|
||||||
|
|
||||||
type ConfirmDialogBaseProps = {
|
type ConfirmDialogBaseProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
|||||||
|
|
||||||
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||||
|
|
||||||
function ConfirmDrawerBody({
|
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = 'Подтвердить',
|
|
||||||
cancelLabel = 'Отмена',
|
|
||||||
destructive,
|
|
||||||
onConfirm,
|
|
||||||
confirmDisabled,
|
|
||||||
confirmLoading,
|
|
||||||
confirmLoadingLabel,
|
|
||||||
controlled,
|
|
||||||
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
|
|
||||||
const confirmText =
|
|
||||||
confirmLoading && confirmLoadingLabel
|
|
||||||
? confirmLoadingLabel
|
|
||||||
: confirmLoading
|
|
||||||
? `${confirmLabel}…`
|
|
||||||
: confirmLabel
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
|
||||||
<DrawerTitle>{title}</DrawerTitle>
|
|
||||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
|
||||||
</DrawerHeader>
|
|
||||||
<DrawerActionsFooter>
|
|
||||||
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
|
|
||||||
{cancelLabel}
|
|
||||||
</DrawerClose>
|
|
||||||
{controlled ? (
|
|
||||||
<Button
|
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
|
||||||
disabled={confirmDisabled || confirmLoading}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<DrawerClose
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
|
||||||
disabled={confirmDisabled || confirmLoading}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</DrawerClose>
|
|
||||||
)}
|
|
||||||
</DrawerActionsFooter>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
const bodyProps: ConfirmDialogBaseProps = {
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||||
title: props.title,
|
const isControlled = props.open !== undefined
|
||||||
description: props.description,
|
const open = isControlled ? props.open : uncontrolledOpen
|
||||||
confirmLabel: props.confirmLabel,
|
|
||||||
cancelLabel: props.cancelLabel,
|
function setOpen(next: boolean) {
|
||||||
destructive: props.destructive,
|
if (!isControlled) setUncontrolledOpen(next)
|
||||||
onConfirm: props.onConfirm,
|
if (isControlled) props.onOpenChange?.(next)
|
||||||
confirmDisabled: props.confirmDisabled,
|
|
||||||
confirmLoading: props.confirmLoading,
|
|
||||||
confirmLoadingLabel: props.confirmLoadingLabel,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.trigger) {
|
const confirmText =
|
||||||
return (
|
props.confirmLoading && props.confirmLoadingLabel
|
||||||
<Drawer swipeDirection="right">
|
? props.confirmLoadingLabel
|
||||||
<DrawerTrigger render={props.trigger} />
|
: props.confirmLoading
|
||||||
<DrawerContent className={confirmDrawerContentClassName}>
|
? `${props.confirmLabel ?? 'Подтвердить'}…`
|
||||||
<ConfirmDrawerBody {...bodyProps} />
|
: (props.confirmLabel ?? 'Подтвердить')
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
function handleConfirm() {
|
||||||
)
|
props.onConfirm()
|
||||||
|
if (!props.confirmLoading && !isControlled) setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
<DrawerContent className={confirmDrawerContentClassName}>
|
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
|
||||||
<ConfirmDrawerBody {...bodyProps} controlled />
|
<AlertDialogContent>
|
||||||
</DrawerContent>
|
<AlertDialogHeader>
|
||||||
</Drawer>
|
<AlertDialogTitle>{props.title}</AlertDialogTitle>
|
||||||
|
{props.description ? (
|
||||||
|
<AlertDialogDescription>{props.description}</AlertDialogDescription>
|
||||||
|
) : null}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={props.confirmLoading}>
|
||||||
|
{props.cancelLabel ?? 'Отмена'}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant={props.destructive ? 'destructive' : 'default'}
|
||||||
|
disabled={props.confirmDisabled || props.confirmLoading}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { DrawerFooter } from '@evobgp/ui/components/drawer'
|
|
||||||
|
|
||||||
/** Shared footer layout for right-side form and confirm drawers. */
|
|
||||||
export function DrawerActionsFooter({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DrawerFooter
|
|
||||||
className={cn(
|
|
||||||
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</DrawerFooter>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formDrawerContentClassName =
|
|
||||||
'flex h-full max-h-dvh flex-col sm:max-w-lg'
|
|
||||||
|
|
||||||
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
|
|
||||||
@@ -1,28 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from '@evobgp/ui/components/empty'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon | ReactNode
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
icon?: ReactNode
|
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
stackedIcon?: boolean
|
||||||
|
centered?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
|
if (typeof icon === 'function') return true
|
||||||
|
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
|
||||||
|
if ('props' in icon) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
stackedIcon = true,
|
||||||
|
centered = true,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<Empty
|
||||||
|
className={cn(
|
||||||
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
|
!centered && className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EmptyHeader className="gap-5 text-center">
|
||||||
|
<EmptyMedia className="mb-0">
|
||||||
|
{customIcon ? (
|
||||||
|
stackedIcon ? (
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
|
{customIcon}
|
||||||
|
</IconStack>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">{customIcon}</div>
|
||||||
|
)
|
||||||
|
) : stackedIcon ? (
|
||||||
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
|
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||||
|
</IconStack>
|
||||||
|
) : (
|
||||||
|
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</EmptyMedia>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||||
|
{description ? (
|
||||||
|
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</EmptyHeader>
|
||||||
|
{action ? (
|
||||||
|
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
|
||||||
|
) : null}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!centered) return body
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
{body}
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="text-sm font-medium">{title}</p>
|
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
{action ? <div className="mt-2">{action}</div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
|
|||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Sheet,
|
||||||
DrawerContent,
|
SheetContent,
|
||||||
DrawerDescription,
|
SheetDescription,
|
||||||
DrawerHeader,
|
SheetFooter,
|
||||||
DrawerTitle,
|
SheetHeader,
|
||||||
} from '@evobgp/ui/components/drawer'
|
SheetTitle,
|
||||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
} from '@evobgp/ui/components/sheet'
|
||||||
|
|
||||||
import {
|
|
||||||
DrawerActionsFooter,
|
|
||||||
formDrawerContentClassName,
|
|
||||||
} from '@/components/drawer-layout'
|
|
||||||
|
|
||||||
interface FormDrawerProps {
|
interface FormDrawerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -25,6 +20,7 @@ interface FormDrawerProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||||
export function FormDrawer({
|
export function FormDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -35,17 +31,22 @@ export function FormDrawer({
|
|||||||
className,
|
className,
|
||||||
}: FormDrawerProps) {
|
}: FormDrawerProps) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<DrawerContent className={cn(formDrawerContentClassName, className)}>
|
<SheetContent
|
||||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
side="right"
|
||||||
<DrawerTitle>{title}</DrawerTitle>
|
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
>
|
||||||
</DrawerHeader>
|
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<SheetTitle>{title}</SheetTitle>
|
||||||
<div className="space-y-4 px-4 py-4">{children}</div>
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
</ScrollArea>
|
</SheetHeader>
|
||||||
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||||
</DrawerContent>
|
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||||
</Drawer>
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
|
|||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||||
|
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
@@ -161,6 +162,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<AppSwitcher />
|
<AppSwitcher />
|
||||||
@@ -196,7 +200,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<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">
|
<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" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
@@ -220,9 +224,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
import {
|
||||||
|
Frame,
|
||||||
|
FramePanel,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
@@ -11,7 +14,7 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from '@evobgp/ui/components/empty'
|
} from '@evobgp/ui/components/empty'
|
||||||
|
|
||||||
/** empty-state-11 pattern adapted to Card surface. */
|
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
|
||||||
export function IllustratedEmptyState({
|
export function IllustratedEmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
|
|||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||||
<EmptyHeader className="items-center gap-5 text-center">
|
<EmptyHeader className="items-center gap-5 text-center">
|
||||||
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
|
|||||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
|
||||||
export type KpiSparklineMetric = {
|
export type KpiSparklineMetric = {
|
||||||
id: string
|
id: string
|
||||||
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
|
||||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||||
const tone = toneStyles[metric.tone]
|
const tone = toneStyles[metric.tone]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0">
|
<Frame dense spacing="sm" className="relative overflow-hidden">
|
||||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
|
||||||
<PanelCorners />
|
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
<PanelCorners />
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="relative z-10 flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="relative z-10 flex items-end justify-between gap-4">
|
||||||
<div className="min-w-0 space-y-2.5">
|
<div className="flex min-w-0 flex-col gap-2.5">
|
||||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
|||||||
</div>
|
</div>
|
||||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,23 +34,29 @@ export function QueryState<T>({
|
|||||||
children,
|
children,
|
||||||
}: QueryStateProps<T>) {
|
}: QueryStateProps<T>) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
return (
|
||||||
|
<div role="status" aria-live="polite" aria-busy="true">
|
||||||
|
{skeleton ?? <DefaultSkeleton />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<div role="alert">
|
||||||
icon={<AlertCircle className="size-8" />}
|
<EmptyState
|
||||||
title="Ошибка загрузки"
|
icon={AlertCircle}
|
||||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
title="Ошибка загрузки"
|
||||||
action={
|
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||||
onRetry ? (
|
action={
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
onRetry ? (
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
Повторить
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
</Button>
|
Повторить
|
||||||
) : null
|
</Button>
|
||||||
}
|
) : null
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (empty || data == null) {
|
if (empty || data == null) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
aria-hidden="true"
|
||||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{item.icon}
|
||||||
{item.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{action.icon ? (
|
{action.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
aria-hidden="true"
|
||||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{action.icon}
|
||||||
{action.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
|
|
||||||
export interface SettingsTabConfig {
|
export interface SettingsTabConfig {
|
||||||
id: string
|
id: string
|
||||||
to: string
|
to: '/settings' | '/tenant-settings'
|
||||||
label: string
|
label: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
}
|
}
|
||||||
@@ -72,19 +72,35 @@ export function SettingsShell({
|
|||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = pathname.startsWith(tab.to)
|
const isActive = pathname.startsWith(tab.to)
|
||||||
|
const className = cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
isMobile && 'shrink-0',
|
||||||
|
!isMobile && 'w-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||||
|
)
|
||||||
|
if (tab.to === '/tenant-settings') {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to="/tenant-settings"
|
||||||
|
search={{ tab: 'bird' }}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
to={tab.to}
|
to="/settings"
|
||||||
|
search={{ tab: 'connection' }}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={className}
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -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 "@evobgp/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<typeof iconTileVariants>["variant"]
|
||||||
|
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||||
|
radius?: VariantProps<typeof iconTileVariants>["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 }
|
||||||
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
ToggleGroup,
|
ToggleGroup,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Save } from 'lucide-react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { MonitorIcon } from 'lucide-react'
|
import { MonitorIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
import { SettingRow } from '@/components/settings/setting-row'
|
||||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
import { SettingsCard } from '@/components/settings/settings-card'
|
||||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
titleAddon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||||
|
export function SettingRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
titleAddon,
|
||||||
|
}: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
{titleAddon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
interface SettingsCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
footerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
|
||||||
|
export function SettingsCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
footerClassName,
|
||||||
|
}: SettingsCardProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter
|
||||||
|
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
interface SettingsFieldGroupProps {
|
||||||
|
legend: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
fieldGroupClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
|
||||||
|
export function SettingsFieldGroup({
|
||||||
|
legend,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
fieldGroupClassName,
|
||||||
|
}: SettingsFieldGroupProps) {
|
||||||
|
return (
|
||||||
|
<FieldSet className={cn('gap-0', className)}>
|
||||||
|
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||||
|
<FieldGroup className={cn('gap-0', fieldGroupClassName)}>
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
*/
|
*/
|
||||||
export const UI_SURFACE = 'frame' as const
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
|
||||||
|
/** Skip link — keyboard: first Tab. */
|
||||||
|
export 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'
|
||||||
|
|
||||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiStatGridClassName = '@container w-full'
|
export const kpiStatGridClassName = '@container w-full'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { SettingsShell } from '@/components/reui-kit/settings-shell'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/_settings')({
|
||||||
|
component: SettingsShell,
|
||||||
|
})
|
||||||
+7
-17
@@ -1,11 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import {
|
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
||||||
type SettingsTab,
|
|
||||||
} from '@/components/settings/settings-tabs-data'
|
|
||||||
|
|
||||||
const settingsSearchSchema = z.object({
|
const settingsSearchSchema = z.object({
|
||||||
tab: z
|
tab: z
|
||||||
@@ -14,7 +11,7 @@ const settingsSearchSchema = z.object({
|
|||||||
reason: z.enum(['token-required']).optional(),
|
reason: z.enum(['token-required']).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
validateSearch: (search) => settingsSearchSchema.parse(search),
|
||||||
})
|
})
|
||||||
@@ -35,17 +32,10 @@ function SettingsComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
<SettingsPageShell
|
||||||
<PageHeader
|
activeTab={tab}
|
||||||
title="Настройки"
|
onTabChange={handleTabChange}
|
||||||
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
|
tokenRequired={tokenRequired}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsPageShell
|
|
||||||
activeTab={tab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
tokenRequired={tokenRequired}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-8
@@ -12,7 +12,6 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
|||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ import {
|
|||||||
} from '@/queries/settings'
|
} from '@/queries/settings'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/tenant-settings')({
|
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||||
component: TenantSettingsComponent,
|
component: TenantSettingsComponent,
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||||
@@ -74,7 +73,7 @@ const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TenantSettingsComponent() {
|
function TenantSettingsComponent() {
|
||||||
const search = useSearch({ from: '/_auth/tenant-settings' })
|
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -116,11 +115,6 @@ function TenantSettingsComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
|
||||||
title="Параметры арендатора"
|
|
||||||
description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BadgeTabs
|
<BadgeTabs
|
||||||
value={search.tab}
|
value={search.tab}
|
||||||
onValueChange={(tab) =>
|
onValueChange={(tab) =>
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/new')({
|
export const Route = createFileRoute('/_auth/modules/new')({
|
||||||
component: NewModuleComponent,
|
component: NewModuleComponent,
|
||||||
@@ -14,11 +20,14 @@ function NewModuleComponent() {
|
|||||||
title="Новый модуль"
|
title="Новый модуль"
|
||||||
description="Создание модуля — через API или будущая форма"
|
description="Создание модуля — через API или будущая форма"
|
||||||
/>
|
/>
|
||||||
<Card>
|
<Frame dense spacing="sm">
|
||||||
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
|
<FrameHeader>
|
||||||
<p>
|
<FrameTitle>Создание через API</FrameTitle>
|
||||||
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
|
<FrameDescription>
|
||||||
</p>
|
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
|
||||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||||
{`POST /v1/modules
|
{`POST /v1/modules
|
||||||
{ "type": "DOMAINS", "name": "Мой список" }`}
|
{ "type": "DOMAINS", "name": "Мой список" }`}
|
||||||
@@ -26,8 +35,8 @@ function NewModuleComponent() {
|
|||||||
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
||||||
Назад к списку
|
Назад к списку
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
|||||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
|
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
|
||||||
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
|
||||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||||
|
|
||||||
## Kit API (`reui-kit/`)
|
## Kit API (`reui-kit/`)
|
||||||
@@ -75,9 +75,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
|
|||||||
|
|
||||||
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
||||||
|
|
||||||
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
|
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`.
|
||||||
|
|
||||||
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
|
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills.
|
||||||
|
|
||||||
## System monitor
|
## System monitor
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ function SheetContent({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon aria-hidden="true" />
|
||||||
/>
|
<span className="sr-only">Закрыть</span>
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Popup>
|
</SheetPrimitive.Popup>
|
||||||
|
|||||||
@@ -194,8 +194,8 @@ function Sidebar({
|
|||||||
side={side}
|
side={side}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>Боковое меню</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -254,7 +254,7 @@ function SidebarTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -262,6 +262,9 @@ function SidebarTrigger({
|
|||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label="Боковое меню"
|
||||||
|
aria-expanded={isMobile ? openMobile : open}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
@@ -269,8 +272,8 @@ function SidebarTrigger({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon aria-hidden="true" />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">Боковое меню</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar="rail"
|
||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
aria-label="Toggle Sidebar"
|
aria-label="Боковое меню"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Боковое меню"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
|||||||
Reference in New Issue
Block a user