Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04b27c215 | ||
|
|
6a25a3d137 | ||
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 | ||
|
|
821f342476 | ||
|
|
4634aac2d1 | ||
|
|
7953ae6e78 | ||
|
|
d14550cda1 |
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -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), [`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 |
|
||||
|------|-----------------|
|
||||
| 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: `@evobgp/ui/components/*`.
|
||||
Импорты UI: `@evobgp/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
|
||||
|
||||
@@ -11,10 +11,14 @@ alwaysApply: true
|
||||
|
||||
| Документ | URL |
|
||||
|----------|-----|
|
||||
| **Introduction** | 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 |
|
||||
| **MCP** | https://reui.io/docs/mcp |
|
||||
| **Agent Skills** | https://reui.io/docs/agent-skills |
|
||||
| **Cursor MCP** | https://reui.io/docs/cursor |
|
||||
| **Blocks** | https://reui.io/blocks |
|
||||
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||
| **License** | https://reui.io/docs/license-setup |
|
||||
@@ -27,6 +31,10 @@ alwaysApply: true
|
||||
|
||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||
|
||||
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
|
||||
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||
|
||||
## Когда ReUI vs shadcn
|
||||
|
||||
| Задача | Registry | Импорт |
|
||||
@@ -39,7 +47,8 @@ alwaysApply: true
|
||||
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
||||
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
|
||||
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
|
||||
|
||||
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||
@@ -66,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`, `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`, …
|
||||
|
||||
@@ -101,6 +110,7 @@ REUI_LICENSE_KEY=
|
||||
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||
- Смешивать Card и Frame на ops-экране
|
||||
- Ставить 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-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)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Registry setup (one-time, per project)
|
||||
|
||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||
|
||||
```json
|
||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
||||
|
||||
## Free vs premium boundary
|
||||
|
||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||
|
||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
# ReUI components
|
||||
|
||||
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
## data-grid (the flagship - read its API every time)
|
||||
|
||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||
|
||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
||||
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||
|
||||
```tsx
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// add sorting/pagination/selection models per the API
|
||||
})
|
||||
|
||||
<DataGrid table={table} recordCount={data.length}>
|
||||
@@ -28,9 +27,9 @@ const table = useReactTable({
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||
|
||||
## event-calendar
|
||||
|
||||
@@ -312,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
||||
|
||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||
|
||||
## icon-tile
|
||||
|
||||
**Required:** one child icon
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<IconTile variant="elevated" size="lg">
|
||||
<PackageIcon />
|
||||
</IconTile>
|
||||
```
|
||||
|
||||
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||
|
||||
## alert
|
||||
|
||||
**Required:** `Alert` > `AlertTitle`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||
|
||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
||||
|
||||
## Free vs premium
|
||||
|
||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||
|
||||
## Component API index
|
||||
|
||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
||||
|
||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||
|
||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||
|
||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
||||
|
||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||
|
||||
@@ -73,4 +73,6 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||
|
||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills). Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||
|
||||
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ChartDonutMetric({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-6', className)}>
|
||||
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -75,7 +75,7 @@ export function ChartDonutMetric({
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
|
||||
@@ -1,62 +1,2 @@
|
||||
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
|
||||
}
|
||||
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
|
||||
export { SettingRow } from '@/components/settings/setting-row'
|
||||
|
||||
@@ -1,53 +1,2 @@
|
||||
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 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
|
||||
export { SettingsCard } from '@/components/settings/settings-card'
|
||||
|
||||
@@ -1,37 +1,2 @@
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
|
||||
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { useState, type ReactElement, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
confirmDrawerContentClassName,
|
||||
DrawerActionsFooter,
|
||||
} from '@/components/drawer-layout'
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
|
||||
type ConfirmDialogBaseProps = {
|
||||
title: string
|
||||
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
||||
|
||||
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||
|
||||
function ConfirmDrawerBody({
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const bodyProps: ConfirmDialogBaseProps = {
|
||||
title: props.title,
|
||||
description: props.description,
|
||||
confirmLabel: props.confirmLabel,
|
||||
cancelLabel: props.cancelLabel,
|
||||
destructive: props.destructive,
|
||||
onConfirm: props.onConfirm,
|
||||
confirmDisabled: props.confirmDisabled,
|
||||
confirmLoading: props.confirmLoading,
|
||||
confirmLoadingLabel: props.confirmLoadingLabel,
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
|
||||
const isControlled = props.open !== undefined
|
||||
const open = isControlled ? props.open : uncontrolledOpen
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
if (isControlled) props.onOpenChange?.(next)
|
||||
}
|
||||
|
||||
if (props.trigger) {
|
||||
return (
|
||||
<Drawer swipeDirection="right">
|
||||
<DrawerTrigger render={props.trigger} />
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
const confirmText =
|
||||
props.confirmLoading && props.confirmLoadingLabel
|
||||
? props.confirmLoadingLabel
|
||||
: props.confirmLoading
|
||||
? `${props.confirmLabel ?? 'Подтвердить'}…`
|
||||
: (props.confirmLabel ?? 'Подтвердить')
|
||||
|
||||
function handleConfirm() {
|
||||
props.onConfirm()
|
||||
if (!props.confirmLoading && !isControlled) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} controlled />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
useCreateCommunityMutation,
|
||||
useUpdateCommunityMutation,
|
||||
} from '@/queries/directories'
|
||||
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
|
||||
|
||||
interface CommunityFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editTarget?: BgpCommunity | null
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
export function CommunityFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editTarget = null,
|
||||
}: CommunityFormDialogProps) {
|
||||
const createMutation = useCreateCommunityMutation()
|
||||
const updateMutation = useUpdateCommunityMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
const [community, setCommunity] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setCommunity(editTarget.community ?? '')
|
||||
setTitle(editTarget.title ?? '')
|
||||
} else {
|
||||
setCommunity('')
|
||||
setTitle('')
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
async function save() {
|
||||
const value = community.trim()
|
||||
if (!value) {
|
||||
toast.error('Укажите community')
|
||||
return
|
||||
}
|
||||
const titleTrimmed = title.trim()
|
||||
try {
|
||||
if (editTarget) {
|
||||
const body: BgpCommunityPatch = {
|
||||
community: value,
|
||||
title: titleTrimmed || '',
|
||||
}
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
const body: BgpCommunityCreate = { community: value }
|
||||
if (titleTrimmed) body.title = titleTrimmed
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-value">Community</Label>
|
||||
<Input
|
||||
id="comm-value"
|
||||
placeholder="65000:100"
|
||||
value={community}
|
||||
onChange={(e) => setCommunity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-title">Название (опционально)</Label>
|
||||
<Input
|
||||
id="comm-title"
|
||||
placeholder="Отображаемое имя"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use CommunityFormDialog */
|
||||
export const CommunityCreateDialog = CommunityFormDialog
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: BgpCommunity) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
||||
() => [
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
||||
const cols: ColumnDef<BgpCommunity>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
|
||||
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
if (canWrite && onEdit) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
canWrite = false,
|
||||
onEdit,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
canWrite?: boolean
|
||||
onEdit?: (row: DohProfile) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
||||
() => [
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
||||
const cols: ColumnDef<DohProfile>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
|
||||
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
if (canWrite && onEdit) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||
),
|
||||
meta: { headerTitle: 'Действия' },
|
||||
})
|
||||
}
|
||||
|
||||
return cols
|
||||
}, [canWrite, onEdit])
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
|
||||
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
|
||||
* @see https://reui.io/docs/components/base/dropdown-menu
|
||||
*/
|
||||
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
|
||||
<MoreHorizontalIcon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<PencilIcon aria-hidden />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
useCreateDohProfileMutation,
|
||||
useUpdateDohProfileMutation,
|
||||
} from '@/queries/directories'
|
||||
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
|
||||
|
||||
interface DohProfileFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editTarget?: DohProfile | null
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
export function DohProfileFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editTarget = null,
|
||||
}: DohProfileFormDialogProps) {
|
||||
const createMutation = useCreateDohProfileMutation()
|
||||
const updateMutation = useUpdateDohProfileMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [timeoutMs, setTimeoutMs] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setName(editTarget.name ?? '')
|
||||
setUrl(editTarget.url ?? '')
|
||||
setTimeoutMs(
|
||||
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
|
||||
? ''
|
||||
: String(editTarget.timeout_ms),
|
||||
)
|
||||
} else {
|
||||
setName('')
|
||||
setUrl('')
|
||||
setTimeoutMs('')
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
async function save() {
|
||||
const trimmedUrl = url.trim()
|
||||
if (!trimmedUrl) {
|
||||
toast.error('Укажите URL DoH')
|
||||
return
|
||||
}
|
||||
let urlOk = true
|
||||
try {
|
||||
new URL(trimmedUrl)
|
||||
} catch {
|
||||
urlOk = false
|
||||
}
|
||||
if (!urlOk) {
|
||||
toast.error('Некорректный URL')
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: number | null = null
|
||||
if (timeoutMs.trim() !== '') {
|
||||
const ms = Number(timeoutMs)
|
||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||
toast.error('Timeout должен быть целым числом > 0')
|
||||
return
|
||||
}
|
||||
timeout = ms
|
||||
}
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
|
||||
try {
|
||||
if (editTarget) {
|
||||
const body: DohProfilePatch = {
|
||||
url: trimmedUrl,
|
||||
name: nameTrimmed || undefined,
|
||||
timeout_ms: timeout,
|
||||
}
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
const body: DohProfileCreate = { url: trimmedUrl }
|
||||
if (nameTrimmed) body.name = nameTrimmed
|
||||
if (timeout !== null) body.timeout_ms = timeout
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
|
||||
description="Резолвер DNS-over-HTTPS для доменных модулей"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="doh-name"
|
||||
placeholder="Control D / AdGuard"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-url">URL</Label>
|
||||
<Input
|
||||
id="doh-url"
|
||||
type="url"
|
||||
placeholder="https://dns.example/dns-query"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="5000"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use DohProfileFormDialog */
|
||||
export const DohProfileCreateDialog = DohProfileFormDialog
|
||||
@@ -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 { 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'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon | ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
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 (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<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}
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
|
||||
import {
|
||||
DrawerActionsFooter,
|
||||
formDrawerContentClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evobgp/ui/components/sheet'
|
||||
|
||||
interface FormDrawerProps {
|
||||
open: boolean
|
||||
@@ -25,6 +20,7 @@ interface FormDrawerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -35,17 +31,22 @@ export function FormDrawer({
|
||||
className,
|
||||
}: FormDrawerProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className={cn(formDrawerContentClassName, className)}>
|
||||
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-4 px-4 py-4">{children}</div>
|
||||
</ScrollArea>
|
||||
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
|
||||
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
|
||||
<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 { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -161,6 +162,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
@@ -196,7 +200,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</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">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
@@ -220,9 +224,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</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}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CircleAlert, CircleCheck, ShieldAlert } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
|
||||
/**
|
||||
* Lookup result status plaque — ReUI Alert + Badge.
|
||||
* @see https://reui.io/docs/components/base/alert
|
||||
* @see https://reui.io/preview/base/components/c-alert-6
|
||||
* @see https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function LookupStatusBanner({
|
||||
status,
|
||||
normalized,
|
||||
matchCount = 0,
|
||||
}: {
|
||||
status: 'found' | 'missing' | 'missing_denied'
|
||||
normalized: string
|
||||
matchCount?: number
|
||||
}) {
|
||||
if (status === 'found') {
|
||||
return (
|
||||
<Alert variant="success" className="items-start py-3">
|
||||
<CircleCheck aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Найдено
|
||||
<Badge variant="success-light" size="sm" radius="full">
|
||||
в списках
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» — {matchCount} совп. в entries / snapshots.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'missing_denied') {
|
||||
return (
|
||||
<Alert variant="warning" className="items-start py-3">
|
||||
<ShieldAlert aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Не найдено
|
||||
<Badge variant="warning-light" size="sm" radius="full">
|
||||
нет прав
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» отсутствует в списках. Для добавления нужно право{' '}
|
||||
<span className="font-medium text-foreground">bgp:modules:write</span>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="warning" className="items-start py-3">
|
||||
<CircleAlert aria-hidden />
|
||||
<AlertTitle className="line-clamp-none flex flex-wrap items-center gap-2">
|
||||
Не найдено
|
||||
<Badge variant="warning-light" size="sm" radius="full">
|
||||
можно добавить
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{normalized}» отсутствует в entries и snapshots. Добавить запись в модуль?
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Plus, Search, ShieldAlert } from 'lucide-react'
|
||||
import { Check, Search } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
|
||||
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
||||
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
||||
import { LookupStatusBanner } from '@/components/lookup/lookup-status-banner'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import {
|
||||
@@ -235,14 +236,11 @@ export function LookupWizard({
|
||||
|
||||
{result.matched ? (
|
||||
<>
|
||||
<Alert variant="success">
|
||||
<Check className="size-4" />
|
||||
<AlertTitle>Уже есть в списках</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{result.normalized}» найден в entries и/или snapshots
|
||||
({result.match_count} совпад.).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LookupStatusBanner
|
||||
status="found"
|
||||
normalized={result.normalized}
|
||||
matchCount={result.match_count}
|
||||
/>
|
||||
<LookupMatchesGrid
|
||||
items={result.matches}
|
||||
isLoading={lookupQ.isFetching}
|
||||
@@ -259,13 +257,10 @@ export function LookupWizard({
|
||||
</>
|
||||
) : canWrite ? (
|
||||
<>
|
||||
<Alert variant="info">
|
||||
<Plus className="size-4" />
|
||||
<AlertTitle>Не найдено в списках</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{result.normalized}» отсутствует. Добавить запись?
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LookupStatusBanner
|
||||
status="missing"
|
||||
normalized={result.normalized}
|
||||
/>
|
||||
<WizardActions>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -281,14 +276,10 @@ export function LookupWizard({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<ShieldAlert className="size-4" />
|
||||
<AlertTitle>Не найдено</AlertTitle>
|
||||
<AlertDescription>
|
||||
«{result.normalized}» отсутствует в списках. У вас нет
|
||||
права добавлять записи (нужно bgp:modules:write).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<LookupStatusBanner
|
||||
status="missing_denied"
|
||||
normalized={result.normalized}
|
||||
/>
|
||||
<WizardActions>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -330,9 +321,9 @@ export function LookupWizard({
|
||||
|
||||
<StepperContent value={STEP_DONE}>
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<Alert variant="success">
|
||||
<Check className="size-4" />
|
||||
<AlertTitle>Запись добавлена</AlertTitle>
|
||||
<Alert variant="success" className="items-start py-3">
|
||||
<Check aria-hidden />
|
||||
<AlertTitle className="line-clamp-none">Запись добавлена</AlertTitle>
|
||||
<AlertDescription>
|
||||
Повторная проверка обновлена. Можно посмотреть совпадения или
|
||||
начать новый запрос.
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import {
|
||||
dohProfileShortLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useUpdateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mod: ModuleRow
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
}
|
||||
|
||||
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||
{ value: 'union', label: dohPolicyRu('union') },
|
||||
]
|
||||
|
||||
export function ModuleEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
}: ModuleEditDialogProps) {
|
||||
const updateMutation = useUpdateModuleMutation()
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||
const [cronExpr, setCronExpr] = useState('')
|
||||
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(mod.name ?? '')
|
||||
setEnabled(mod.enabled !== false)
|
||||
setPriority(String(mod.priority ?? 0))
|
||||
setRefreshIntervalSec(
|
||||
mod.refresh_interval_sec === null || mod.refresh_interval_sec === undefined
|
||||
? ''
|
||||
: String(mod.refresh_interval_sec),
|
||||
)
|
||||
setCronExpr(mod.cron_expr ?? '')
|
||||
setDefaultCommunityId(mod.default_community_id ?? null)
|
||||
setDohResolverPolicy(mod.doh_resolver_policy ?? 'primary_only')
|
||||
setDohProfileIds(moduleDohProfileIds(mod))
|
||||
}, [mod, open])
|
||||
|
||||
function toggleDohProfile(id: string, checked: boolean) {
|
||||
setDohProfileIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(id)) return prev
|
||||
return [...prev, id]
|
||||
}
|
||||
return prev.filter((x) => x !== id)
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error('Укажите название модуля')
|
||||
return
|
||||
}
|
||||
|
||||
const priorityNum = Number(priority)
|
||||
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||
toast.error('Приоритет должен быть целым числом')
|
||||
return
|
||||
}
|
||||
|
||||
let refresh: number | null = null
|
||||
if (refreshIntervalSec.trim() !== '') {
|
||||
const n = Number(refreshIntervalSec)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||
return
|
||||
}
|
||||
refresh = n
|
||||
}
|
||||
|
||||
const body: ModulePatch = {
|
||||
name: trimmedName,
|
||||
enabled,
|
||||
priority: priorityNum,
|
||||
refresh_interval_sec: refresh,
|
||||
cron_expr: cronExpr.trim() || null,
|
||||
default_community_id: defaultCommunityId,
|
||||
}
|
||||
|
||||
if (isDomains) {
|
||||
body.doh_resolver_policy = dohResolverPolicy
|
||||
body.doh_profile_ids = dohProfileIds
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: mod.id, body })
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Редактировать модуль"
|
||||
description={`${moduleTypeRu(mod.type)} · ${mod.type}`}
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={updateMutation.isPending} onClick={() => void save()}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-name">Название</Label>
|
||||
<Input
|
||||
id="mod-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в refresh и apply.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-priority">Приоритет</Label>
|
||||
<Input
|
||||
id="mod-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="mod-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто = по умолчанию"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-cron">Cron (опционально)</Label>
|
||||
<Input
|
||||
id="mod-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => setDohResolverPolicy(v as DohResolverPolicy)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
{dohProfiles.map((p) => {
|
||||
const checked = dohProfileIds.includes(p.id)
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={`mod-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-doh-${p.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{dohProfileShortLabel(p.id, dohProfiles)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||
{p.url}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -2,29 +2,25 @@ import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
dohProfileLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import { communityLabel, moduleDohProfileIds } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import type { AsEntry, BgpCommunity, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
mod: ModuleRow | null
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
asEntries: AsEntry[]
|
||||
entriesCount?: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
asEntries,
|
||||
entriesCount = 0,
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
@@ -33,6 +29,49 @@ export function ModuleKpiCards({
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
const isAsPrefixes = mod.type === 'AS_PREFIXES'
|
||||
const community = communityLabel(mod.default_community_id, communities)
|
||||
|
||||
const entriesFooter = isAsPrefixes ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
) : isDomains ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{mod.default_community_id ? community : 'без community'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{entriesCount} · в модуле
|
||||
</Badge>
|
||||
)
|
||||
|
||||
const policyItem: KpiStatItem = isDomains
|
||||
? {
|
||||
id: 'doh',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: 'DoH',
|
||||
footer: (
|
||||
<Badge variant={dohIds.length > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{dohIds.length > 0 ? dohPolicyRu(mod.doh_resolver_policy) : 'без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
: {
|
||||
id: 'community',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: community,
|
||||
label: 'Community',
|
||||
footer: (
|
||||
<Badge variant="outline" size="sm">
|
||||
по умолчанию
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
@@ -63,29 +102,11 @@ export function ModuleKpiCards({
|
||||
id: 'prefixes',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
footer: (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||
{dohIds.length > 0
|
||||
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||
: ' · без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
value: isAsPrefixes ? String(asPrefixTotal) : String(entriesCount),
|
||||
label: isAsPrefixes ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: entriesFooter,
|
||||
},
|
||||
policyItem,
|
||||
]
|
||||
|
||||
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||
|
||||
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
|
||||
import {
|
||||
isReadyCheckOk,
|
||||
isSystemReady,
|
||||
readyCheckStatusLabel,
|
||||
} from '@/lib/metrics'
|
||||
import { readyCheckRu } from '@/lib/ui-labels'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
store_backend: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
|
||||
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
|
||||
label: string
|
||||
subtitle?: string
|
||||
icon: typeof Database
|
||||
iconClassName?: string
|
||||
status: string
|
||||
statusLabel: string
|
||||
}
|
||||
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
|
||||
}) {
|
||||
const data = useMemo<ReadyCheckRow[]>(() => {
|
||||
const checks = ready.checks ?? {}
|
||||
const systemReady = isSystemReady(ready.status)
|
||||
const rows: ReadyCheckRow[] = [
|
||||
{
|
||||
id: 'liveness',
|
||||
label: 'Живучесть',
|
||||
subtitle: '/v1/health',
|
||||
icon: HeartPulse,
|
||||
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
|
||||
status: health?.ok ? 'ok' : 'error',
|
||||
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
|
||||
},
|
||||
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
|
||||
label: 'Готовность',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
status: ready.status === 'ok' ? 'ok' : 'warning',
|
||||
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
|
||||
iconClassName: systemReady ? 'text-success' : 'text-warning',
|
||||
status: systemReady ? 'ok' : 'warning',
|
||||
statusLabel: systemReady ? 'Готов' : 'Не готов',
|
||||
},
|
||||
]
|
||||
for (const key of Object.keys(checks)) {
|
||||
const value = checks[key]
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
const ok = isReadyCheckOk(value)
|
||||
rows.push({
|
||||
id: key,
|
||||
label: readyCheckRu(key),
|
||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||
iconClassName: ok ? 'text-success' : 'text-destructive',
|
||||
status: ok ? 'ok' : 'error',
|
||||
statusLabel: ok ? 'В норме' : 'Ошибка',
|
||||
statusLabel: readyCheckStatusLabel(value, ok),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
|
||||
cell: ({ row }) => {
|
||||
const Icon = row.original.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-8 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-3.5',
|
||||
row.original.iconClassName,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.label}
|
||||
subtitle={row.original.subtitle}
|
||||
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
<div className="flex justify-start">
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Plus, SearchIcon, ActivityIcon, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import {
|
||||
getPeerFilterFieldValue,
|
||||
@@ -15,13 +17,19 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||
import { useDeletePeerMutation } from '@/queries/network'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||
* @see https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
|
||||
function peerDeleteLabel(p: PeerRow): string {
|
||||
return p.name?.trim() || p.neighbor || p.id
|
||||
}
|
||||
|
||||
const PEER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'established', label: 'Established' },
|
||||
@@ -89,7 +97,9 @@ export function NetworkPeersCard({
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const deleteMutation = useDeletePeerMutation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<PeerRow | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||
|
||||
const addButton = useMemo(
|
||||
@@ -102,6 +112,33 @@ export function NetworkPeersCard({
|
||||
[],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<PeerRow, unknown>[]>(
|
||||
() => [
|
||||
...peerColumns,
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
title="Удалить"
|
||||
onClick={() => setDeleteTarget(row.original)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
@@ -114,7 +151,7 @@ export function NetworkPeersCard({
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||
getFilterFieldValue={getPeerFilterFieldValue}
|
||||
columns={peerColumns}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
@@ -129,7 +166,34 @@ export function NetworkPeersCard({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
title="Удалить пира?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `${peerDeleteLabel(deleteTarget)} будет удалён. Запустится reconcile пиров и apply на спикеры.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
confirmLoadingLabel="Удаление…"
|
||||
destructive
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!deleteTarget) return
|
||||
void deleteMutation
|
||||
.mutateAsync(deleteTarget.id)
|
||||
.then(() => setDeleteTarget(null))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
|
||||
export function DonutBreakdownCard({
|
||||
title,
|
||||
description,
|
||||
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
|
||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
className="h-full"
|
||||
actions={
|
||||
badge ? (
|
||||
<Badge variant="success-light" className="hidden sm:inline-flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-auto size-36 shrink-0">
|
||||
<div className="relative mx-auto size-36 shrink-0 sm:mx-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
|
||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -11,7 +14,7 @@ import {
|
||||
EmptyTitle,
|
||||
} 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({
|
||||
title,
|
||||
description,
|
||||
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<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}
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||
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 = {
|
||||
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 }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Frame dense spacing="sm" className="relative overflow-hidden">
|
||||
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<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-muted-foreground truncate text-xs leading-4">{metric.label}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="relative z-10 flex items-end justify-between gap-4">
|
||||
<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">
|
||||
{metric.value}
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,23 +34,29 @@ export function QueryState<T>({
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div role="alert">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
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'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'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',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
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 { kpiCols } from './kpi-cols'
|
||||
|
||||
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'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',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<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 {
|
||||
id: string
|
||||
to: string
|
||||
to: '/settings' | '/tenant-settings'
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
@@ -72,19 +72,35 @@ export function SettingsShell({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
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 (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
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',
|
||||
)}
|
||||
className={className}
|
||||
>
|
||||
{tab.icon}
|
||||
{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 { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
ToggleGroup,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'lucide-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 { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { MonitorIcon } from 'lucide-react'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether session may create/update directories (`bgp:directories:write`).
|
||||
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
||||
*/
|
||||
export function sessionCanWriteDirectories(session: {
|
||||
role?: string
|
||||
kind?: string
|
||||
is_admin?: boolean
|
||||
permissions?: readonly string[]
|
||||
} | null | undefined): boolean {
|
||||
if (!session) return false
|
||||
const jwtPath =
|
||||
session.kind === 'jwt' ||
|
||||
session.is_admin === true ||
|
||||
(session.permissions?.length ?? 0) > 0
|
||||
if (jwtPath) {
|
||||
return (
|
||||
session.is_admin === true ||
|
||||
hasPermission(session.permissions ?? [], 'bgp:directories:write')
|
||||
)
|
||||
}
|
||||
const role = (session.role ?? '').toLowerCase()
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||
export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isReadyCheckOk,
|
||||
isSystemReady,
|
||||
readinessBreakdown,
|
||||
readyCheckStatusLabel,
|
||||
} from '@/lib/metrics/readiness-breakdown'
|
||||
|
||||
describe('isSystemReady', () => {
|
||||
it('accepts ready and ok', () => {
|
||||
expect(isSystemReady('ready')).toBe(true)
|
||||
expect(isSystemReady('ok')).toBe(true)
|
||||
expect(isSystemReady('READY')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects not_ready and empty', () => {
|
||||
expect(isSystemReady('not_ready')).toBe(false)
|
||||
expect(isSystemReady(undefined)).toBe(false)
|
||||
expect(isSystemReady('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isReadyCheckOk', () => {
|
||||
it('parses backend string checks as ok', () => {
|
||||
expect(isReadyCheckOk('ok')).toBe(true)
|
||||
expect(isReadyCheckOk('memory')).toBe(true)
|
||||
expect(isReadyCheckOk('unavailable')).toBe(false)
|
||||
})
|
||||
|
||||
it('parses boolean and object forms', () => {
|
||||
expect(isReadyCheckOk(true)).toBe(true)
|
||||
expect(isReadyCheckOk(false)).toBe(false)
|
||||
expect(isReadyCheckOk({ ok: true })).toBe(true)
|
||||
expect(isReadyCheckOk({ ok: false, error: 'down' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readyCheckStatusLabel', () => {
|
||||
it('labels memory and failures', () => {
|
||||
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
|
||||
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
|
||||
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readinessBreakdown', () => {
|
||||
it('counts healthy handleReady payload without false failures', () => {
|
||||
const slices = readinessBreakdown(
|
||||
{
|
||||
status: 'ready',
|
||||
checks: { store: 'ok', jobs: 'memory', postgres: 'ok' },
|
||||
},
|
||||
true,
|
||||
)
|
||||
expect(slices.find((s) => s.key === 'checks-fail')).toBeUndefined()
|
||||
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(3)
|
||||
expect(slices.find((s) => s.key === 'health')?.count).toBe(1)
|
||||
})
|
||||
|
||||
it('counts unavailable checks as failures', () => {
|
||||
const slices = readinessBreakdown(
|
||||
{
|
||||
status: 'not_ready',
|
||||
checks: { store: 'unavailable', jobs: 'memory' },
|
||||
},
|
||||
true,
|
||||
)
|
||||
expect(slices.find((s) => s.key === 'checks-fail')?.count).toBe(1)
|
||||
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(1)
|
||||
})
|
||||
|
||||
it('returns API down slice when health fails', () => {
|
||||
const slices = readinessBreakdown({ status: 'ready', checks: { store: 'ok' } }, false)
|
||||
expect(slices).toHaveLength(1)
|
||||
expect(slices[0]?.key).toBe('health-fail')
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,65 @@ import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
|
||||
/** Значение check из GET /v1/ready (строка, boolean или объект). */
|
||||
export type ReadyCheckValue = boolean | string | { ok?: boolean; error?: string } | null | undefined
|
||||
|
||||
const OK_STRINGS = new Set(['ok', 'ready', 'memory', 'true', 'healthy', 'up'])
|
||||
const FAIL_STRINGS = new Set([
|
||||
'unavailable',
|
||||
'error',
|
||||
'not_ready',
|
||||
'failed',
|
||||
'down',
|
||||
'false',
|
||||
'unhealthy',
|
||||
])
|
||||
|
||||
/** Top-level status GET /v1/ready: API отдаёт `ready`, не `ok`. */
|
||||
export function isSystemReady(status: string | null | undefined): boolean {
|
||||
if (!status) return false
|
||||
const normalized = status.trim().toLowerCase()
|
||||
return normalized === 'ready' || normalized === 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерпретация check value по контракту handleReady:
|
||||
* store/postgres → "ok" | "unavailable"; jobs → "memory"; store_backend → "memory".
|
||||
*/
|
||||
export function isReadyCheckOk(value: ReadyCheckValue): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (OK_STRINGS.has(normalized)) return true
|
||||
if (FAIL_STRINGS.has(normalized)) return false
|
||||
// неизвестная непустая строка — считать OK (информативный статус бэкенда)
|
||||
return normalized.length > 0
|
||||
}
|
||||
if (value && typeof value === 'object') return value.ok === true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Человекочитаемый статус check для UI. */
|
||||
export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): string {
|
||||
if (!ok) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const n = value.trim().toLowerCase()
|
||||
if (n === 'unavailable') return 'Недоступно'
|
||||
if (n === 'not_ready') return 'Не готов'
|
||||
return value
|
||||
}
|
||||
if (value && typeof value === 'object' && value.error) return value.error
|
||||
return 'Ошибка'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const n = value.trim().toLowerCase()
|
||||
if (n === 'memory') return 'Memory'
|
||||
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
|
||||
if (n) return value
|
||||
}
|
||||
return 'В норме'
|
||||
}
|
||||
|
||||
export function readinessBreakdown(
|
||||
ready: ReadyStatus | null | undefined,
|
||||
healthOk: boolean,
|
||||
@@ -28,7 +81,7 @@ export function readinessBreakdown(
|
||||
let failCount = 0
|
||||
|
||||
for (const value of Object.values(checks)) {
|
||||
if (checkOk(value)) okCount += 1
|
||||
if (isReadyCheckOk(value)) okCount += 1
|
||||
else failCount += 1
|
||||
}
|
||||
|
||||
@@ -61,9 +114,11 @@ export function readinessBreakdown(
|
||||
if (slices.length === 1 && okCount === 0 && failCount === 0) {
|
||||
slices.push({
|
||||
key: 'ready',
|
||||
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
|
||||
label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов',
|
||||
count: 1,
|
||||
color: 'var(--color-chart-4)',
|
||||
color: isSystemReady(ready?.status)
|
||||
? 'var(--color-chart-1)'
|
||||
: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
/** Short label for forms / chips — name preferred, else hostname from URL. */
|
||||
export function dohProfileShortLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||
const p = dohProfiles.find((d) => d.id === id)
|
||||
if (!p) return `${id.slice(0, 8)}…`
|
||||
const name = p.name?.trim()
|
||||
if (name) return name
|
||||
try {
|
||||
return new URL(p.url).hostname
|
||||
} catch {
|
||||
return p.url
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ export function readyCheckRu(key: string): string {
|
||||
return 'Хранилище'
|
||||
case 'jobs':
|
||||
return 'Очередь задач'
|
||||
case 'store_backend':
|
||||
return 'Бэкенд хранилища'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
*/
|
||||
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). */
|
||||
export const kpiStatGridClassName = '@container w-full'
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
BgpCommunityCreate,
|
||||
BgpCommunityPatch,
|
||||
CommunitiesResponse,
|
||||
DohProfile,
|
||||
DohProfileCreate,
|
||||
DohProfilePatch,
|
||||
DohProfilesResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const directoriesKeys = {
|
||||
all: ['directories'] as const,
|
||||
@@ -23,3 +34,59 @@ export function directoriesDohQueryOptions() {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCommunityMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: BgpCommunityCreate) =>
|
||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество создано')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCommunityMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
||||
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество обновлено')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateDohProfileMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: DohProfileCreate) =>
|
||||
apiMutate<DohProfile>('/v1/doh-profiles', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('DoH-профиль создан')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateDohProfileMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: DohProfilePatch }) =>
|
||||
apiMutate<DohProfile>(`/v1/doh-profiles/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('DoH-профиль обновлён')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось обновить DoH-профиль'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import { overviewKeys } from '@/queries/overview'
|
||||
import type {
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
Page,
|
||||
@@ -16,6 +20,14 @@ export const modulesKeys = {
|
||||
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
|
||||
}
|
||||
|
||||
function invalidateModules(qc: ReturnType<typeof useQueryClient>, id?: string) {
|
||||
void qc.invalidateQueries({ queryKey: modulesKeys.all })
|
||||
void qc.invalidateQueries({ queryKey: overviewKeys.modules() })
|
||||
if (id) {
|
||||
void qc.invalidateQueries({ queryKey: modulesKeys.detail(id) })
|
||||
}
|
||||
}
|
||||
|
||||
export function modulesListQueryOptions() {
|
||||
return queryOptions<ModulesResponse>({
|
||||
queryKey: modulesKeys.list(),
|
||||
@@ -51,3 +63,16 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
||||
queryFn: () => apiJSON<ModuleEntriesPage>(path),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateModuleMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: ModulePatch }) =>
|
||||
apiMutate<ModuleRow>(`/v1/modules/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success('Модуль обновлён')
|
||||
invalidateModules(qc, vars.id)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить модуль'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export interface HealthStatus {
|
||||
|
||||
export interface ReadyStatus {
|
||||
status?: string
|
||||
checks?: Record<string, boolean | { ok?: boolean; error?: string }>
|
||||
/** Backend: string ("ok"|"memory"|"unavailable"), boolean, or { ok, error }. */
|
||||
checks?: Record<string, boolean | string | { ok?: boolean; error?: string }>
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
|
||||
@@ -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 { z } from 'zod'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||
import {
|
||||
type SettingsTab,
|
||||
} from '@/components/settings/settings-tabs-data'
|
||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
||||
|
||||
const settingsSearchSchema = z.object({
|
||||
tab: z
|
||||
@@ -14,7 +11,7 @@ const settingsSearchSchema = z.object({
|
||||
reason: z.enum(['token-required']).optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||
component: SettingsComponent,
|
||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
||||
})
|
||||
@@ -35,17 +32,10 @@ function SettingsComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
|
||||
/>
|
||||
|
||||
<SettingsPageShell
|
||||
activeTab={tab}
|
||||
onTabChange={handleTabChange}
|
||||
tokenRequired={tokenRequired}
|
||||
/>
|
||||
</div>
|
||||
<SettingsPageShell
|
||||
activeTab={tab}
|
||||
onTabChange={handleTabChange}
|
||||
tokenRequired={tokenRequired}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+2
-8
@@ -12,7 +12,6 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
|
||||
export const Route = createFileRoute('/_auth/tenant-settings')({
|
||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||
component: TenantSettingsComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||
@@ -74,7 +73,7 @@ const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||
}
|
||||
|
||||
function TenantSettingsComponent() {
|
||||
const search = useSearch({ from: '/_auth/tenant-settings' })
|
||||
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
||||
const navigate = Route.useNavigate()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
@@ -116,11 +115,6 @@ function TenantSettingsComponent() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Параметры арендатора"
|
||||
description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
|
||||
/>
|
||||
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
@@ -1,23 +1,41 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
||||
import { BookText, Globe, Plus, RefreshCw, Tags } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import {
|
||||
CommunityFormDialog,
|
||||
} from '@/components/directories/community-create-dialog'
|
||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||
import {
|
||||
DohProfileFormDialog,
|
||||
} from '@/components/directories/doh-profile-create-dialog'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanWriteDirectories } from '@/lib/auth'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
||||
import type { BgpCommunity, DohProfile } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/directories')({
|
||||
component: DirectoriesComponent,
|
||||
})
|
||||
|
||||
function DirectoriesComponent() {
|
||||
const [communityOpen, setCommunityOpen] = useState(false)
|
||||
const [communityEdit, setCommunityEdit] = useState<BgpCommunity | null>(null)
|
||||
const [dohOpen, setDohOpen] = useState(false)
|
||||
const [dohEdit, setDohEdit] = useState<DohProfile | null>(null)
|
||||
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
const canWrite = sessionCanWriteDirectories(sessionQ.data)
|
||||
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
|
||||
@@ -58,6 +76,40 @@ function DirectoriesComponent() {
|
||||
},
|
||||
]
|
||||
|
||||
function openCreateCommunity() {
|
||||
setCommunityEdit(null)
|
||||
setCommunityOpen(true)
|
||||
}
|
||||
|
||||
function openEditCommunity(row: BgpCommunity) {
|
||||
setCommunityEdit(row)
|
||||
setCommunityOpen(true)
|
||||
}
|
||||
|
||||
function openCreateDoh() {
|
||||
setDohEdit(null)
|
||||
setDohOpen(true)
|
||||
}
|
||||
|
||||
function openEditDoh(row: DohProfile) {
|
||||
setDohEdit(row)
|
||||
setDohOpen(true)
|
||||
}
|
||||
|
||||
const addCommunityButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={openCreateCommunity}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const addDohButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={openCreateDoh}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
@@ -92,6 +144,7 @@ function DirectoriesComponent() {
|
||||
<FrameDataGrid
|
||||
title="Сообщества BGP"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
actions={addCommunityButton}
|
||||
>
|
||||
<QueryState
|
||||
data={communities}
|
||||
@@ -100,13 +153,16 @@ function DirectoriesComponent() {
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
emptyAction={addCommunityButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesCommunitiesGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||
canWrite={canWrite}
|
||||
onEdit={openEditCommunity}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
@@ -114,7 +170,11 @@ function DirectoriesComponent() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-0">
|
||||
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||
<FrameDataGrid
|
||||
title="DoH профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
actions={addDohButton}
|
||||
>
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
@@ -122,19 +182,43 @@ function DirectoriesComponent() {
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
emptyAction={addDohButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesDohGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||
canWrite={canWrite}
|
||||
onEdit={openEditDoh}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
|
||||
{canWrite ? (
|
||||
<>
|
||||
<CommunityFormDialog
|
||||
open={communityOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCommunityOpen(open)
|
||||
if (!open) setCommunityEdit(null)
|
||||
}}
|
||||
editTarget={communityEdit}
|
||||
/>
|
||||
<DohProfileFormDialog
|
||||
open={dohOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDohOpen(open)
|
||||
if (!open) setDohEdit(null)
|
||||
}}
|
||||
editTarget={dohEdit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { ArrowLeft, Pencil, RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ModuleEditDialog } from '@/components/modules/module-edit-dialog'
|
||||
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
|
||||
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
|
||||
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
|
||||
import { sessionCanWriteModules } from '@/lib/auth'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
directoriesCommunitiesQueryOptions,
|
||||
directoriesDohQueryOptions,
|
||||
@@ -25,8 +29,12 @@ export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||
function ModuleDetailComponent() {
|
||||
const { moduleId } = Route.useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
||||
const mod = detail.data
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
@@ -57,6 +65,7 @@ function ModuleDetailComponent() {
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
const dohProfiles = dohQ.data?.items ?? []
|
||||
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
|
||||
const entriesCount = entriesQuery.data?.items.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -69,6 +78,12 @@ function ModuleDetailComponent() {
|
||||
<ArrowLeft />
|
||||
К списку
|
||||
</Button>
|
||||
{canWrite && mod ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil />
|
||||
Редактировать
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -103,8 +118,8 @@ function ModuleDetailComponent() {
|
||||
<ModuleKpiCards
|
||||
mod={m}
|
||||
communities={communities}
|
||||
dohProfiles={dohProfiles}
|
||||
asEntries={asEntries}
|
||||
entriesCount={entriesCount}
|
||||
loading={detail.isLoading || communitiesQ.isLoading}
|
||||
/>
|
||||
|
||||
@@ -119,6 +134,16 @@ function ModuleDetailComponent() {
|
||||
onRetry={() => entriesQuery.refetch()}
|
||||
onChanged={onEntriesChanged}
|
||||
/>
|
||||
|
||||
{canWrite ? (
|
||||
<ModuleEditDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
mod={m}
|
||||
communities={communities}
|
||||
dohProfiles={dohProfiles}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/new')({
|
||||
component: NewModuleComponent,
|
||||
@@ -14,11 +20,14 @@ function NewModuleComponent() {
|
||||
title="Новый модуль"
|
||||
description="Создание модуля — через API или будущая форма"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
|
||||
</p>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Создание через API</FrameTitle>
|
||||
<FrameDescription>
|
||||
Форма в 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">
|
||||
{`POST /v1/modules
|
||||
{ "type": "DOMAINS", "name": "Мой список" }`}
|
||||
@@ -26,8 +35,8 @@ function NewModuleComponent() {
|
||||
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
||||
Назад к списку
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ function MonitoringComponent() {
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
<PageHeader
|
||||
title="Мониторинг"
|
||||
description={`Состояние API, BGP и задач для диагностики инцидентов${
|
||||
@@ -110,11 +110,11 @@ function MonitoringComponent() {
|
||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
|
||||
<TabsContent value="system" className="mt-0 flex flex-col gap-2 md:gap-3">
|
||||
{analyticsLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||
<MonitoringHealthCard
|
||||
healthOk={healthQ.data?.ok ?? false}
|
||||
ready={readyQ.data}
|
||||
@@ -124,7 +124,7 @@ function MonitoringComponent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||
<FrameDataGrid
|
||||
title="Доступность и готовность"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
@@ -149,23 +149,24 @@ function MonitoringComponent() {
|
||||
</span>
|
||||
}
|
||||
description="GET /v1/bird/status"
|
||||
contentClassName="py-4"
|
||||
className="h-full"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
<SegmentedProgressCard
|
||||
title="Задачи"
|
||||
description="Последние 100 задач · GET /v1/jobs"
|
||||
@@ -189,8 +190,8 @@ function MonitoringComponent() {
|
||||
footer={`В выборке: ${jobs.length} задач`}
|
||||
/>
|
||||
{failedJobs.length > 0 ? (
|
||||
<PanelCard title="Последние ошибки" contentClassName="space-y-2 py-4">
|
||||
<ul className="space-y-2">
|
||||
<PanelCard title="Последние ошибки" contentClassName="px-5 py-4">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{failedJobs.map((job) => (
|
||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -218,28 +219,29 @@ function MonitoringComponent() {
|
||||
</span>
|
||||
}
|
||||
description="Краткая шпаргалка для первичной диагностики"
|
||||
contentClassName="py-4"
|
||||
className="h-full"
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
|
||||
<code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||||
его логи.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
||||
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
||||
и <code className="text-xs">jobs</code> в проверках.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
|
||||
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
|
||||
проверьте последние неуспешные задачи.
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="flex flex-col gap-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
|
||||
<code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||||
его логи.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
|
||||
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
||||
и <code className="text-xs">jobs</code> в проверках.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
|
||||
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
|
||||
проверьте последние неуспешные задачи.
|
||||
</li>
|
||||
</ul>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -282,7 +284,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
|
||||
: null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Установлено / всего</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`.
|
||||
Иерархия: **ReUI PRO > shadcn primitives**.
|
||||
|
||||
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
|
||||
Карта: [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)
|
||||
|
||||
## Surface
|
||||
|
||||
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## 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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -70,9 +70,8 @@ function SheetContent({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon aria-hidden="true" />
|
||||
<span className="sr-only">Закрыть</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
|
||||
@@ -194,8 +194,8 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>Боковое меню</SheetTitle>
|
||||
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
@@ -254,7 +254,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -262,6 +262,9 @@ function SidebarTrigger({
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label="Боковое меню"
|
||||
aria-expanded={isMobile ? openMobile : open}
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
@@ -269,8 +272,8 @@ function SidebarTrigger({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeftIcon aria-hidden="true" />
|
||||
<span className="sr-only">Боковое меню</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label="Боковое меню"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
title="Боковое меню"
|
||||
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",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
Reference in New Issue
Block a user