Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764a4e5b4e | ||
|
|
2d29a892f9 | ||
|
|
d9bec85c02 | ||
|
|
d56405af7b | ||
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 | ||
|
|
653bc6cc91 | ||
|
|
e0d695f2a4 | ||
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b | ||
|
|
35e262b5ae | ||
|
|
8267141136 | ||
|
|
34a29ad337 | ||
|
|
b04b27c215 | ||
|
|
6a25a3d137 | ||
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 | ||
|
|
821f342476 | ||
|
|
4634aac2d1 | ||
|
|
7953ae6e78 | ||
|
|
d14550cda1 | ||
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 | ||
|
|
b13c233679 | ||
|
|
37f28dfcfd | ||
|
|
6c6e76fca3 | ||
|
|
26f5172f88 | ||
|
|
8fe74c1d3b | ||
|
|
13e3d21ce2 | ||
|
|
a0cfbcdab1 | ||
|
|
c3369059af | ||
|
|
53ce80c9ff | ||
|
|
1e04e91dd8 | ||
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 | ||
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 |
@@ -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 `0e224b0281`.** 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 `3bdbad788a`.** 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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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 `0e224b0281`.** 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 `3bdbad788a`.** 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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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,6 +0,0 @@
|
||||
{
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
|
||||
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
|
||||
@@ -11,9 +11,9 @@
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
||||
"url": "https://mcp.reui.io",
|
||||
"headers": {
|
||||
"X-Reui-Style": "base-nova"
|
||||
"Authorization": "Bearer <REUI_LICENSE_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
||||
## Dependency Management
|
||||
|
||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||
|
||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||
*Проверка:* review.
|
||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||
*Проверка:* CI job `openapi`.
|
||||
|
||||
---
|
||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
||||
```powershell
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||
|
||||
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).
|
||||
|
||||
## MUST
|
||||
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| 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/*`. IconTile: `@/components/reui/icon-tile`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- `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 без IconTile
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# ReUI MCP — обязательно (PRO + free)
|
||||
|
||||
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||
|
||||
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
|
||||
| Документ | 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 |
|
||||
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||
|
||||
## Primary MCP
|
||||
|
||||
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
|
||||
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
|
||||
|
||||
**Обязательно** цитировать `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 | Импорт |
|
||||
|--------|----------|--------|
|
||||
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
|
||||
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
|
||||
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||
| Filters | `@reui` | `@/components/reui/filters` |
|
||||
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||
| 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 / 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).
|
||||
|
||||
## MCP workflow
|
||||
|
||||
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
|
||||
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
|
||||
2. Страница целиком → `compose_page`; секция → `get_block`
|
||||
3. `get_component` для API primitives из `componentsUsed`
|
||||
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
|
||||
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
|
||||
6. Adapt by reuse → kit / route
|
||||
7. `validate_usage` + `get_audit_checklist`
|
||||
|
||||
## Размещение
|
||||
|
||||
| Слой | Путь | Импорт |
|
||||
|------|------|--------|
|
||||
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**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`, `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`, …
|
||||
|
||||
## License
|
||||
|
||||
```env
|
||||
# .env.local (gitignored)
|
||||
REUI_LICENSE_KEY=
|
||||
```
|
||||
|
||||
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||
|
||||
## Эталоны preview
|
||||
|
||||
| Зона | Preview |
|
||||
|------|---------|
|
||||
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | https://reui.io/preview/base/settings-16 |
|
||||
| Auth | https://reui.io/preview/base/auth-13 |
|
||||
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Копипаст с reui.io без CLI
|
||||
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
|
||||
- Radix-варианты docs — только Base UI
|
||||
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||
- 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)
|
||||
|
||||
## Чеклист
|
||||
|
||||
- [ ] `user-reui` search/get_block + previewUrl
|
||||
- [ ] `surface: frame`
|
||||
- [ ] CLI add из `apps/web` при новом item
|
||||
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
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 `0e224b0281`.** 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 `3bdbad788a`.** 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
|
||||
@@ -64,7 +64,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)
|
||||
|
||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
||||
# Web UI — React + shadcn/ui + ReUI
|
||||
|
||||
**Источники правды:**
|
||||
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
|
||||
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
|
||||
- shadcn/ui React: https://ui.shadcn.com/docs/components
|
||||
- ReUI Base UI: https://reui.io/docs/components/base/<name>
|
||||
- ReUI llms.txt: https://reui.io/llms.txt
|
||||
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
|
||||
|
||||
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
|
||||
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
|
||||
|
||||
## Слои UI
|
||||
|
||||
@@ -22,16 +22,19 @@ alwaysApply: false
|
||||
|------|------|------------|
|
||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
|
||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||
|
||||
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
|
||||
|
||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||
|
||||
## Правила
|
||||
|
||||
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
|
||||
*Rationale:* единый источник правды и API.
|
||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
||||
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
|
||||
|
||||
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
|
||||
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
|
||||
@@ -45,8 +48,8 @@ alwaysApply: false
|
||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||
|
||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
||||
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||
|
||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||
@@ -70,7 +73,7 @@ alwaysApply: false
|
||||
|
||||
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
|
||||
|
||||
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||
|
||||
@@ -95,7 +98,11 @@ pnpm --filter @evobgp/web run build
|
||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||
*Проверка:* review `queries/*.ts`.
|
||||
|
||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 `0e224b0281`.** 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 `3bdbad788a`.** 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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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)).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.golangci.yml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
redocly.yaml
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
*.test
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
+50
-21
@@ -1,55 +1,84 @@
|
||||
# Gitea Actions
|
||||
|
||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
| Job | PR | push в main |
|
||||
|-----|-----|-------------|
|
||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||
| commitlint | да | — |
|
||||
| **release** | — | semantic-release + docker push (один run) |
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
## Release (job в ci.yaml)
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||
|
||||
После успешных quality gates на **push в main** job **release**:
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
**`docker_hub_token`**: PAT Docker Hub (Settings → Actions → Secrets, можно на уровне организации). Логин перед зеркалом `docker.io` и `bake`, чтобы не ловить anonymous 429. Логин: `docker_hub_username` (если задан) или `gitea.actor`.
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shts.su/<owner>/<имя>:latest
|
||||
git.shts.su/<owner>/<имя>:v1.2.3
|
||||
git.shts.su/<owner>/<имя>:1.2.3
|
||||
git.shts.su/<owner>/<имя>:<short-sha>
|
||||
git.shts.su/<owner>/<имя>:sha-<full-sha>
|
||||
git.shx.one/<owner>/<имя>:latest
|
||||
git.shx.one/<owner>/<имя>:v1.2.3
|
||||
git.shx.one/<owner>/<имя>:1.2.3
|
||||
git.shx.one/<owner>/<имя>:<short-sha>
|
||||
git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||
```
|
||||
|
||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||
|
||||
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||
|
||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shts.su/myuser/evobgp-api:1.2.3
|
||||
docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Summary
|
||||
|
||||
<!-- 1–3 bullets: what and why -->
|
||||
|
||||
## Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Docs updated if behavior/API changed (DOC-02)
|
||||
- [ ] Conventional Commits (EN title / RU body)
|
||||
|
||||
### Backend (if Go / OpenAPI / migrations)
|
||||
|
||||
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
|
||||
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
|
||||
- [ ] Migration pairs postgres+sqlite (DEP-03)
|
||||
|
||||
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
|
||||
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck`
|
||||
- [ ] `pnpm --filter @evobgp/web run lint`
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
- [ ] `pnpm --filter @evobgp/web run test`
|
||||
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
|
||||
|
||||
### KPI / Quick Actions
|
||||
|
||||
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
|
||||
@@ -0,0 +1,156 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
+18
-356
@@ -1,363 +1,25 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
FILES="$(git diff --name-only "$base" "$head")"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Lint commit messages
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
npm ci
|
||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
needs: [changes, openapi, web, go, bird2]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.changes.result == 'success' &&
|
||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Install release tooling
|
||||
run: npm ci
|
||||
- name: Verify releasable commit messages
|
||||
run: node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shts.su
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f .release-version ]; then
|
||||
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd "${{ github.workspace }}/deploy/docker"
|
||||
sh write-bake-override.sh
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
||||
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
docker_hub_token: ${{ secrets.docker_hub_token }}
|
||||
docker_hub_username: ${{ secrets.docker_hub_username }}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
docker_hub_token:
|
||||
required: false
|
||||
docker_hub_username:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly, codegen check
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: go-hash
|
||||
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.GOMODCACHE }}
|
||||
${{ env.GOCACHE }}
|
||||
${{ env.GOBIN }}
|
||||
${{ env.GOLANGCI_LINT_CACHE }}
|
||||
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||
go-${{ runner.os }}-1.24-
|
||||
- name: Download modules
|
||||
env:
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go mod download
|
||||
- name: Vet
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
- name: golangci-lint
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v1.64.8
|
||||
run: sh scripts/ci/golangci-lint.sh
|
||||
- name: Test
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
if: >-
|
||||
inputs.is_pull_request &&
|
||||
(needs.changes.outputs.docker_go == 'true' ||
|
||||
needs.changes.outputs.docker_web == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Docker Hub
|
||||
if: secrets.docker_hub_token != ''
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username || gitea.actor }}
|
||||
password: ${{ secrets.docker_hub_token }}
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -21,9 +21,14 @@ Thumbs.db
|
||||
|
||||
# Env / secrets (local only)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||
deploy/compose/runtime-logs/
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
.codegraph/daemon.pid
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
},
|
||||
"reui": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.reui.io/api/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `0e224b0281`.** 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 `3bdbad788a`.** 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" } }
|
||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
||||
}
|
||||
```
|
||||
|
||||
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||
|
||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||
|
||||
## Installing
|
||||
@@ -52,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 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `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,37 @@ 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
|
||||
|
||||
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||
<EventCalendarNav />
|
||||
<EventCalendarContent />
|
||||
</EventCalendar>
|
||||
```
|
||||
|
||||
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||
|
||||
## gantt
|
||||
|
||||
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||
<GanttNav />
|
||||
<GanttView />
|
||||
</Gantt>
|
||||
```
|
||||
|
||||
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||
|
||||
## kanban
|
||||
|
||||
@@ -79,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
@@ -284,6 +349,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
-1
@@ -37,7 +37,7 @@
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shts.su",
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
|
||||
@@ -69,6 +69,10 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
|
||||
|
||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||
**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 стека.
|
||||
|
||||
@@ -22,8 +22,8 @@ Control plane для управления префиксами, модулями
|
||||
|
||||
```powershell
|
||||
cd deploy\compose
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shts.su/<owner>
|
||||
docker login git.shts.su
|
||||
Copy-Item .env.example .env -Force # EVOBGP_REGISTRY=git.shx.one/<owner>
|
||||
docker login git.shx.one
|
||||
docker compose --profile microvps pull
|
||||
docker compose --profile microvps up -d
|
||||
```
|
||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
||||
|
||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||
|
||||
Лицензия и условия использования — по политике владельца репозитория.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# EvoBGP web (Vite) — переменные окружения.
|
||||
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||
# ---------------------------------------------------------------------------
|
||||
# REUI_LICENSE_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||
# ---------------------------------------------------------------------------
|
||||
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-portal SSO
|
||||
# ---------------------------------------------------------------------------
|
||||
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||
# Пример:
|
||||
# VITE_AUTH_ENABLED=true
|
||||
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
#
|
||||
# Backend опционально может отдавать GET /v1/auth/config
|
||||
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||
# VITE_AUTH_ENABLED=false
|
||||
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
|
||||
*
|
||||
* Env:
|
||||
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
|
||||
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
|
||||
* - EVOBGP_E2E_TOKEN (default dev)
|
||||
*/
|
||||
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
|
||||
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
|
||||
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
|
||||
|
||||
test.describe('EvoBGP smoke', () => {
|
||||
test('API health and modules list', async ({ request }) => {
|
||||
const health = await request.get(`${apiBase}/v1/health`)
|
||||
expect(health.ok()).toBeTruthy()
|
||||
|
||||
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect([200, 401]).toContain(mods.status())
|
||||
})
|
||||
|
||||
test('UI loads when available', async ({ page }) => {
|
||||
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
|
||||
await page.goto(uiBase)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,9 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
|
||||
+13
-6
@@ -9,10 +9,14 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
|
||||
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@date-fns/tz": "^1.5.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -23,8 +27,8 @@
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
@@ -36,26 +40,29 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
@@ -2,9 +2,7 @@ import { useState } from 'react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -47,45 +45,38 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={false}
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||
import {
|
||||
ResourcePage,
|
||||
createSearchFilterField,
|
||||
createTextFilterQuery,
|
||||
type DataGridColumnDef,
|
||||
} from '@/components/reui-kit'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
||||
]
|
||||
|
||||
export function AccessApiKeysGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
@@ -21,6 +28,7 @@ export function AccessApiKeysGrid({
|
||||
onRevoke,
|
||||
rotatePending = false,
|
||||
revokePending = false,
|
||||
actions,
|
||||
}: {
|
||||
items: ApiKey[]
|
||||
isLoading?: boolean
|
||||
@@ -28,13 +36,25 @@ export function AccessApiKeysGrid({
|
||||
onRevoke: (id: string) => void
|
||||
rotatePending?: boolean
|
||||
revokePending?: boolean
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||
createTextFilterQuery('search'),
|
||||
)
|
||||
|
||||
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridNameCell
|
||||
icon={KeyRound}
|
||||
title={row.original.name}
|
||||
subtitle={`${row.original.prefix}…`}
|
||||
/>
|
||||
),
|
||||
minSize: 160,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +65,6 @@ export function AccessApiKeysGrid({
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
@@ -77,20 +89,21 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
meta: { headerTitle: 'Использован' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
size: 88,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -135,23 +148,24 @@ export function AccessApiKeysGrid({
|
||||
[onRevoke, onRotate, revokePending, rotatePending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
<ResourcePage
|
||||
title="API-ключи"
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
filterFields={filterFields}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||
getFilterFieldValue={(row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
|
||||
}
|
||||
columns={columns}
|
||||
data={items}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
primaryAction={actions}
|
||||
pinLastColumn
|
||||
emptyState={{ title: 'Нет ключей' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
|
||||
/** Chart Frame shell — chart-1 spacing, not Card.
|
||||
* @see https://reui.io/preview/base/chart-1
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
function deltaBadgeVariant(
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||
if (tone === 'success') return 'success-light'
|
||||
if (tone === 'warning') return 'warning-light'
|
||||
if (tone === 'destructive') return 'destructive-light'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||
return {
|
||||
id: item.label,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
footer: item.delta ? (
|
||||
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||
{item.delta.label}
|
||||
</Badge>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||
export function AnalyticsKpiRow({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: AnalyticsKpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
items={items.map(toKpiItem)}
|
||||
embedded
|
||||
className={cn(className)}
|
||||
aria-label="Показатели"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
@@ -40,9 +42,10 @@ 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>
|
||||
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
@@ -75,7 +78,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 (
|
||||
|
||||
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
|
||||
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
|
||||
return (
|
||||
<DonutBreakdownCard
|
||||
title="Доступность системы"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
description="Проверки живучести и готовности"
|
||||
slices={slices}
|
||||
centerLabel="Проверки"
|
||||
badge={healthOk ? 'API OK' : undefined}
|
||||
badge={healthOk ? 'API в норме' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,18 +40,18 @@ export function NetworkOverviewAnalyticsCard({
|
||||
/>
|
||||
<SegmentedProgressCard
|
||||
title="Спикеры"
|
||||
description="Доступность live-агентов"
|
||||
description="Доступность агентов"
|
||||
primary={{
|
||||
value: `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
label: 'Online',
|
||||
label: 'В сети',
|
||||
percent: speakersPct,
|
||||
}}
|
||||
secondary={{
|
||||
value: net.speakersTotal - net.speakersOnline,
|
||||
label: 'Offline',
|
||||
label: 'Не в сети',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@evobgp/ui/components/sidebar'
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem key={app.id} disabled>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -40,17 +40,18 @@ export function BadgeTabs({
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
className={cn('w-full', className)}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-4 w-full justify-start gap-6',
|
||||
'mb-3.5 w-full min-w-0 justify-start gap-4',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { SidebarTrigger } from "@evobgp/ui/components/sidebar"
|
||||
import { APPS, ENVIRONMENTS, WORKSPACES } from "./data"
|
||||
import { Logo } from "./logo"
|
||||
import { SystemStats } from "./system-stats"
|
||||
import { ChevronsUpDownIcon, CheckIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
// ── Org Switcher ──
|
||||
|
||||
function OrgSwitcher() {
|
||||
const [activeOrg, setActiveOrg] = useState(WORKSPACES[0])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" className="text-foreground" />
|
||||
}
|
||||
>
|
||||
<activeOrg.Logo className="size-3" />
|
||||
<span className="sr-only sm:not-sr-only">{activeOrg.name}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{WORKSPACES.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
onClick={() => setActiveOrg(ws)}
|
||||
className={cn(activeOrg.id === ws.id && "bg-accent")}
|
||||
>
|
||||
<ws.Logo className="size-5 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm">{ws.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{ws.tier}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrg.id === ws.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── App Switcher ──
|
||||
|
||||
function AppSwitcher() {
|
||||
const [activeApp, setActiveApp] = useState(
|
||||
APPS.find((a) => a.isActive) ?? APPS[0]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">{activeApp.name}</span>
|
||||
<span className="sm:hidden">{activeApp.name.split(" ")[0]}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Applications</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{APPS.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
onClick={() => setActiveApp(app)}
|
||||
className={cn(activeApp.id === app.id && "bg-accent")}
|
||||
>
|
||||
{app.icon}
|
||||
{app.name}
|
||||
{activeApp.id === app.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create Application
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Environment Switcher ──
|
||||
|
||||
function EnvironmentSwitcher() {
|
||||
const [activeEnv, setActiveEnv] = useState(
|
||||
ENVIRONMENTS.find((e) => e.isActive) ?? ENVIRONMENTS[0]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{activeEnv.name}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Environments</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{ENVIRONMENTS.map((env) => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
onClick={() => setActiveEnv(env)}
|
||||
className={cn(activeEnv.id === env.id && "bg-accent")}
|
||||
>
|
||||
{env.icon}
|
||||
{env.name}
|
||||
{activeEnv.id === env.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Site Header ──
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-50 flex w-full items-center border-b">
|
||||
<div className="flex h-(--header-height) w-full items-center gap-2 px-4">
|
||||
{/* Mobile sidebar trigger */}
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="hidden items-center pl-0.5 md:flex">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-1">
|
||||
<BreadcrumbItem>
|
||||
<OrgSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-xs opacity-60">
|
||||
/
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<AppSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-xs opacity-60">
|
||||
/
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<EnvironmentSwitcher />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SystemStats />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { SidebarInset, SidebarProvider } from "@evobgp/ui/components/sidebar"
|
||||
|
||||
import { AppHeader } from "./app-header"
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<SidebarProvider
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[--sidebar:var(--color-background)]",
|
||||
"[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]",
|
||||
"[--sidebar-accent-foreground:var(--color-primary)]"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "260px",
|
||||
"--sidebar-width-icon": "62px",
|
||||
"--header-height": "50px",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
<div className="flex flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
</div>
|
||||
<div className="border-border/40 bg-muted/40 min-h-[1000px] flex-1 rounded-lg border border-dashed" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
useSidebar,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
|
||||
import { NEWS_ARTICLES } from "./data"
|
||||
import { NavMain } from "./nav-main"
|
||||
import { NavUser } from "./nav-user"
|
||||
import { SearchForm } from "./search-form"
|
||||
import { SidebarNews } from "./sidebar-news"
|
||||
import { SidebarRailToggle } from "./sidebar-rail-toggle"
|
||||
|
||||
// ── App Sidebar ──
|
||||
|
||||
export function AppSidebar() {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="top-(--header-height) h-[calc(100svh-var(--header-height))]!"
|
||||
>
|
||||
{/* Header */}
|
||||
<SidebarHeader className="flex flex-row items-center px-4 in-data-[state=collapsed]:flex-col in-data-[state=collapsed]:items-start in-data-[state=collapsed]:justify-center">
|
||||
<div className="w-full flex-1 pt-2">
|
||||
<SearchForm />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Sidebar */}
|
||||
<SidebarContent className="gap-2 px-2">
|
||||
<NavMain />
|
||||
|
||||
<div className="mt-auto">
|
||||
<SidebarNews articles={NEWS_ARTICLES} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
{/* Footer */}
|
||||
{/* customize: mt-2 keeps a fixed gap above the footer so the news card never butts against it when the viewport shrinks and the content scrolls */}
|
||||
<SidebarFooter className="mt-2 px-4">
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
|
||||
{!isMobile && <SidebarRailToggle />}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import { type ComponentType, type ReactNode } from "react"
|
||||
import { GlobeIcon, ShieldIcon, SmartphoneIcon, RocketIcon, FlaskConicalIcon, CodeIcon, LayoutDashboardIcon, UsersIcon, Building2Icon, ShieldCheckIcon, WebhookIcon, KeyRoundIcon, CpuIcon, MonitorIcon, MemoryStickIcon, WifiIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type NavChild = {
|
||||
id: string
|
||||
label: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
badge?: string | number
|
||||
isActive?: boolean
|
||||
children?: NavChild[]
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
tier: string
|
||||
Logo: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export type App = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type NewsArticle = {
|
||||
href: string
|
||||
title: string
|
||||
summary: string
|
||||
image: string
|
||||
}
|
||||
|
||||
export type ResourceMetric = {
|
||||
id: string
|
||||
label: string
|
||||
unit: string
|
||||
icon: ReactNode
|
||||
seedRange: [number, number]
|
||||
color: string
|
||||
spikeThreshold: number
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
id: string
|
||||
name: string
|
||||
memoryMb: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Logos ──
|
||||
|
||||
// Acme: indigo → violet → fuchsia diagonal sweep
|
||||
function AcmeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-acme"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#4f46e5" />
|
||||
<stop offset="35%" stopColor="#7c3aed" />
|
||||
<stop offset="70%" stopColor="#a21caf" />
|
||||
<stop offset="100%" stopColor="#db2777" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-acme)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Starter: rose → orange → amber → lime horizontal sweep
|
||||
function StarterLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-starter"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#f43f5e" />
|
||||
<stop offset="33%" stopColor="#f97316" />
|
||||
<stop offset="66%" stopColor="#eab308" />
|
||||
<stop offset="100%" stopColor="#84cc16" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-starter)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Enterprise: sky → blue → indigo vertical sweep
|
||||
function EnterpriseLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-enterprise"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="35%" stopColor="#3b82f6" />
|
||||
<stop offset="70%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#14b8a6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-enterprise)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const USER = {
|
||||
name: "Nick Bold",
|
||||
email: "nick@reui.io",
|
||||
initials: "NB",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
|
||||
} as const
|
||||
|
||||
export const WORKSPACES: Workspace[] = [
|
||||
{ id: "acme", name: "Acme Inc", tier: "Pro", Logo: AcmeLogo },
|
||||
{ id: "starter", name: "Starter Kit", tier: "Free", Logo: StarterLogo },
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
tier: "Enterprise",
|
||||
Logo: EnterpriseLogo,
|
||||
},
|
||||
]
|
||||
|
||||
export const APPS: App[] = [
|
||||
{
|
||||
id: "web-app",
|
||||
name: "Web App",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<GlobeIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
name: "Admin Panel",
|
||||
icon: (
|
||||
<ShieldIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mobile",
|
||||
name: "Mobile App",
|
||||
icon: (
|
||||
<SmartphoneIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const ENVIRONMENTS: Environment[] = [
|
||||
{
|
||||
id: "production",
|
||||
name: "Production",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<RocketIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "staging",
|
||||
name: "Staging",
|
||||
icon: (
|
||||
<FlaskConicalIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "development",
|
||||
name: "Development",
|
||||
icon: (
|
||||
<CodeIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<LayoutDashboardIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Users",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "all-users", label: "All Users" },
|
||||
{ id: "invitations", label: "Invitations" },
|
||||
{ id: "roles", label: "Roles", isActive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "organizations",
|
||||
label: "Organizations",
|
||||
icon: (
|
||||
<Building2Icon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "org-list", label: "All Organizations" },
|
||||
{ id: "org-settings", label: "Settings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: (
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "attack-protection", label: "Attack Protection" },
|
||||
{ id: "fraud", label: "Fraud Detection" },
|
||||
{ id: "audit-log", label: "Audit Log" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "webhooks",
|
||||
label: "Webhooks",
|
||||
icon: (
|
||||
<WebhookIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: "API Keys",
|
||||
icon: (
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const RESOURCE_METRICS: ResourceMetric[] = [
|
||||
{
|
||||
id: "cpu",
|
||||
label: "CPU",
|
||||
unit: "%",
|
||||
icon: (
|
||||
<CpuIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [20, 65],
|
||||
color: "var(--color-blue-500)",
|
||||
spikeThreshold: 60,
|
||||
},
|
||||
{
|
||||
id: "gpu",
|
||||
label: "GPU",
|
||||
unit: "%",
|
||||
icon: (
|
||||
<MonitorIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [10, 50],
|
||||
color: "var(--color-emerald-500)",
|
||||
spikeThreshold: 45,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: "Memory",
|
||||
unit: "GB",
|
||||
icon: (
|
||||
<MemoryStickIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [4, 12],
|
||||
color: "var(--color-amber-500)",
|
||||
spikeThreshold: 12,
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
unit: "Mbps",
|
||||
icon: (
|
||||
<WifiIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [5, 80],
|
||||
color: "var(--color-violet-500)",
|
||||
spikeThreshold: 70,
|
||||
},
|
||||
]
|
||||
|
||||
export const AGENTS: Agent[] = [
|
||||
{
|
||||
id: "ingest",
|
||||
name: "Ingest Worker",
|
||||
memoryMb: 256,
|
||||
color: "var(--color-indigo-500)",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
name: "Transform Pipeline",
|
||||
memoryMb: 512,
|
||||
color: "var(--color-purple-500)",
|
||||
},
|
||||
{
|
||||
id: "query",
|
||||
name: "Query Engine",
|
||||
memoryMb: 384,
|
||||
color: "var(--color-sky-500)",
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "Export Service",
|
||||
memoryMb: 128,
|
||||
color: "var(--color-teal-500)",
|
||||
},
|
||||
]
|
||||
|
||||
export const NEWS_ARTICLES: NewsArticle[] = [
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.3",
|
||||
title: "Multi-theme support is here",
|
||||
summary:
|
||||
"Switch between Vega, Nova, Maia, Lyra, and Mira themes across all components.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.2",
|
||||
title: "Icon library v2 released",
|
||||
summary:
|
||||
"Five icon libraries unified under a single API with automatic mapping.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.1",
|
||||
title: "Sidebar layout patterns guide",
|
||||
summary:
|
||||
"Learn best practices for building responsive sidebar layouts with collapsible navigation.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<Item
|
||||
className="bg-primary text-primary-foreground flex size-7 shrink-0 items-center justify-center p-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="25.668 25.1352 49.6644 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-3.5"
|
||||
>
|
||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { NAV_MAIN, type NavChild, type NavItem } from "./data"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// ── Nav Sub Items ──
|
||||
|
||||
function NavSubItem({ child }: { child: NavChild }) {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuSubButton render={<a href="#" />} isActive={child.isActive}>
|
||||
{child.label}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSubMenu({ id, items }: { id: string; items: NavChild[] }) {
|
||||
return (
|
||||
<SidebarMenuSub id={`subnav-${id}`}>
|
||||
{items.map((child) => (
|
||||
<NavSubItem key={child.id} child={child} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collapsible Nav Item ──
|
||||
|
||||
export function CollapsibleNavItem({
|
||||
item,
|
||||
}: {
|
||||
item: NavItem & { children: NavChild[] }
|
||||
}) {
|
||||
const [open, setOpen] = useState(() => item.children.some((c) => c.isActive))
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-expanded={open}
|
||||
aria-controls={`subnav-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
<ChevronRightIcon className={cn(
|
||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
|
||||
open && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</SidebarMenuButton>
|
||||
|
||||
{open && <NavSubMenu id={item.id} items={item.children} />}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf Nav Item ──
|
||||
|
||||
export function LeafNavItem({ item }: { item: NavItem }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
render={<a href="#" />}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nav Main ──
|
||||
|
||||
export function NavMain() {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
{/* Sidebar */}
|
||||
<SidebarGroupLabel className="in-data-[state=collapsed]:hidden">
|
||||
Platform
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.25">
|
||||
{NAV_MAIN.map((item) =>
|
||||
item.children ? (
|
||||
<CollapsibleNavItem
|
||||
key={item.id}
|
||||
item={item as NavItem & { children: NavChild[] }}
|
||||
/>
|
||||
) : (
|
||||
<LeafNavItem key={item.id} item={item} />
|
||||
)
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { USER } from "./data"
|
||||
import { SunIcon, MoonIcon, MonitorIcon, ChevronsUpDownIcon, UserIcon, SettingsIcon, InboxIcon, PaletteIcon, LogOutIcon } from "lucide-react"
|
||||
|
||||
// ── Theme Toggle ──
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: (
|
||||
<SunIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: (
|
||||
<MoonIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: (
|
||||
<MonitorIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? "system") : "system"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nav User ──
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-7 rounded-md">
|
||||
<AvatarImage src={USER.avatar} alt={USER.name} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-medium">{USER.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{USER.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4 opacity-50 group-data-[collapsible=icon]:hidden" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="w-56"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2.5 py-2">
|
||||
<Avatar className="size-8 rounded-md">
|
||||
<AvatarImage src={USER.avatar} alt={USER.name} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-foreground text-sm font-semibold">
|
||||
{USER.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{USER.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<UserIcon aria-hidden="true" />
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<InboxIcon aria-hidden="true" />
|
||||
Manage Accounts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="cursor-default focus:bg-transparent!"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<PaletteIcon aria-hidden="true" />
|
||||
Theme
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<LogOutIcon aria-hidden="true" />
|
||||
Sign Out
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useId, useState, type ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@evobgp/ui/components/dialog"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import { Kbd } from "@evobgp/ui/components/kbd"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
export function SearchForm({ ...props }: ComponentProps<"form">) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const searchInputId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form {...props}>
|
||||
{/* Sidebar */}
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
id="search"
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Search...
|
||||
</Button>
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none" />
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search your workspace content.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md px-4 py-2 **:data-[slot=dialog-close]:top-1/2 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:-translate-y-1/2 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3">
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none size-4 opacity-60 select-none" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Type to search..."
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
import { type NewsArticle } from "./data"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const OFFSET_FACTOR = 4
|
||||
const SCALE_FACTOR = 0.03
|
||||
const OPACITY_FACTOR = 0.1
|
||||
const COMPLETED_DISPLAY_MS = 2700
|
||||
|
||||
// ── News Card ──
|
||||
|
||||
function NewsCard({
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
onDismiss,
|
||||
hideContent,
|
||||
href,
|
||||
active,
|
||||
}: {
|
||||
title: string
|
||||
summary: string
|
||||
image?: string
|
||||
onDismiss?: () => void
|
||||
hideContent?: boolean
|
||||
href?: string
|
||||
active?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{
|
||||
start: number
|
||||
delta: number
|
||||
startTime: number
|
||||
maxDelta: number
|
||||
}>({ start: 0, delta: 0, startTime: 0, maxDelta: 0 })
|
||||
const animation = useRef<Animation>(undefined)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const onDragMove = (e: globalThis.PointerEvent) => {
|
||||
if (!ref.current) return
|
||||
const dx = e.clientX - drag.current.start
|
||||
drag.current.delta = dx
|
||||
drag.current.maxDelta = Math.max(drag.current.maxDelta, Math.abs(dx))
|
||||
ref.current.style.setProperty("--dx", dx.toString())
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (!ref.current) return
|
||||
const cardWidth = ref.current.getBoundingClientRect().width
|
||||
const translateX = Math.sign(drag.current.delta) * cardWidth
|
||||
|
||||
animation.current = ref.current.animate(
|
||||
{ opacity: 0, transform: `translateX(${translateX}px)` },
|
||||
{ duration: 150, easing: "ease-in-out", fill: "forwards" }
|
||||
)
|
||||
animation.current.onfinish = () => onDismiss?.()
|
||||
}
|
||||
|
||||
const stopDragging = (cancelled: boolean) => {
|
||||
unbindListeners()
|
||||
if (!ref.current) return
|
||||
setDragging(false)
|
||||
|
||||
const dx = drag.current.delta
|
||||
if (Math.abs(dx) > ref.current.clientWidth / (cancelled ? 2 : 3)) {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
animation.current = ref.current.animate(
|
||||
{ transform: "translateX(0)" },
|
||||
{ duration: 150, easing: "ease-in-out" }
|
||||
)
|
||||
animation.current.onfinish = () =>
|
||||
ref.current?.style.setProperty("--dx", "0")
|
||||
|
||||
drag.current = { start: 0, delta: 0, startTime: 0, maxDelta: 0 }
|
||||
}
|
||||
|
||||
const onDragEnd = () => stopDragging(false)
|
||||
const onDragCancel = () => stopDragging(true)
|
||||
|
||||
const onPointerDown = (e: ReactPointerEvent) => {
|
||||
if (!active || !ref.current || animation.current?.playState === "running")
|
||||
return
|
||||
|
||||
bindListeners()
|
||||
setDragging(true)
|
||||
drag.current.start = e.clientX
|
||||
drag.current.startTime = Date.now()
|
||||
drag.current.delta = 0
|
||||
ref.current.style.setProperty("--w", ref.current.clientWidth.toString())
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (!ref.current || !href) return
|
||||
if (
|
||||
drag.current.maxDelta < ref.current.clientWidth / 10 &&
|
||||
(!drag.current.startTime || Date.now() - drag.current.startTime < 250)
|
||||
) {
|
||||
window.open(href, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
|
||||
const bindListeners = () => {
|
||||
document.addEventListener("pointermove", onDragMove)
|
||||
document.addEventListener("pointerup", onDragEnd)
|
||||
document.addEventListener("pointercancel", onDragCancel)
|
||||
}
|
||||
|
||||
const unbindListeners = () => {
|
||||
document.removeEventListener("pointermove", onDragMove)
|
||||
document.removeEventListener("pointerup", onDragEnd)
|
||||
document.removeEventListener("pointercancel", onDragCancel)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border bg-card relative cursor-grab border p-3 text-[0.8125rem] shadow-xs select-none",
|
||||
"rounded-lg",
|
||||
"translate-x-[calc(var(--dx)*1px)] rotate-[calc(var(--dx)*0.05deg)] opacity-[calc(1-max(var(--dx),-1*var(--dx))/var(--w)/2)]",
|
||||
"transition-shadow data-[dragging=true]:cursor-grabbing data-[dragging=true]:shadow-md"
|
||||
)}
|
||||
style={{ "--dx": 0, "--w": 1 } as CSSProperties}
|
||||
data-dragging={dragging}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={cn(hideContent && "invisible")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-foreground line-clamp-1 font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<p className="text-muted-foreground line-clamp-2 h-10 leading-5">
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/40 bg-muted relative mt-3 aspect-video w-full shrink-0 overflow-hidden border",
|
||||
"rounded-md"
|
||||
)}
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-rows-[0fr] overflow-hidden opacity-0 transition-[grid-template-rows,opacity] duration-200",
|
||||
"sm:group-has-[*[data-dragging=true]]:grid-rows-[1fr] sm:group-has-[*[data-dragging=true]]:opacity-100",
|
||||
"sm:group-hover:group-data-[active=true]:grid-rows-[1fr] sm:group-hover:group-data-[active=true]:opacity-100",
|
||||
"has-focus-visible:grid-rows-[1fr] has-focus-visible:opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="flex items-center justify-between pt-3 text-xs">
|
||||
<a
|
||||
href={href ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground font-medium transition-colors duration-75"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dismiss()
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-75"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar News ──
|
||||
|
||||
export function SidebarNews({ articles }: { articles: NewsArticle[] }) {
|
||||
const [dismissedIds, setDismissedIds] = useState<string[]>([])
|
||||
const cards = articles.filter(({ href }) => !dismissedIds.includes(href))
|
||||
const cardCount = cards.length
|
||||
const [showCompleted, setShowCompleted] = useState(cardCount > 0)
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
if (cardCount === 0) {
|
||||
timeout = setTimeout(() => setShowCompleted(false), COMPLETED_DISPLAY_MS)
|
||||
}
|
||||
return () => clearTimeout(timeout)
|
||||
}, [cardCount])
|
||||
|
||||
if (!cards.length && !showCompleted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group px-3 in-data-[state=collapsed]:hidden"
|
||||
data-active={cardCount !== 0}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
{cards.toReversed().map(({ href, title, summary, image }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"absolute top-0 left-0 size-full scale-(--scale) transition-[opacity,transform] duration-200",
|
||||
cardCount - idx > 3
|
||||
? [
|
||||
"opacity-0 sm:group-hover:translate-y-(--y) sm:group-hover:opacity-(--opacity)",
|
||||
"sm:group-has-[*[data-dragging=true]]:translate-y-(--y) sm:group-has-[*[data-dragging=true]]:opacity-(--opacity)",
|
||||
]
|
||||
: "translate-y-(--y) opacity-(--opacity)"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--y": `-${(cardCount - (idx + 1)) * OFFSET_FACTOR}%`,
|
||||
"--scale": 1 - (cardCount - (idx + 1)) * SCALE_FACTOR,
|
||||
"--opacity":
|
||||
cardCount - (idx + 1) >= 6
|
||||
? 0
|
||||
: 1 - (cardCount - (idx + 1)) * OPACITY_FACTOR,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden={idx !== cardCount - 1}
|
||||
{...(idx !== cardCount - 1 ? { inert: true } : {})}
|
||||
>
|
||||
<NewsCard
|
||||
title={title}
|
||||
summary={summary}
|
||||
image={image}
|
||||
href={href}
|
||||
hideContent={cardCount - idx > 2}
|
||||
active={idx === cardCount - 1}
|
||||
onDismiss={() =>
|
||||
setDismissedIds([href, ...dismissedIds.slice(0, 50)])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Invisible spacer to hold layout height */}
|
||||
<div className="pointer-events-none invisible" aria-hidden="true">
|
||||
<NewsCard title="Title" summary="Description" />
|
||||
</div>
|
||||
|
||||
{/* All-caught-up state */}
|
||||
{showCompleted && !cardCount && (
|
||||
<div className="absolute inset-0 flex size-full flex-col items-center justify-end gap-3 pb-4">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
You're all caught up!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { useSidebar } from "@evobgp/ui/components/sidebar"
|
||||
|
||||
export function SidebarRailToggle() {
|
||||
const { state, toggleSidebar } = useSidebar()
|
||||
const isExpanded = state === "expanded"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
onClick={toggleSidebar}
|
||||
style={{
|
||||
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
|
||||
}}
|
||||
className={cn(
|
||||
"group/rail fixed z-30 flex h-12 w-7 cursor-pointer items-center pl-2 outline-none",
|
||||
"top-[calc(var(--header-height)+50%)] -translate-y-1/2",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||
"focus-visible:rounded-sm",
|
||||
"transition-[left] duration-200 ease-linear"
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
|
||||
"origin-bottom transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
|
||||
"origin-top transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"border-border bg-foreground text-background absolute left-full -ml-2 border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
|
||||
"rounded-md",
|
||||
"pointer-events-none -translate-x-0.5 opacity-0 transition-all duration-200 ease-out",
|
||||
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Badge } from "@evobgp/ui/components/badge"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
AGENTS,
|
||||
RESOURCE_METRICS,
|
||||
type Agent,
|
||||
type ResourceMetric,
|
||||
} from "./data"
|
||||
import { ActivityIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
const HISTORY_LENGTH = 20
|
||||
const UPDATE_INTERVAL_MS = 1000
|
||||
const SPIKE_COLOR = "var(--color-red-500)"
|
||||
const STATS_RANDOM_SEED = 1337
|
||||
const DEMO_REFERENCE_DATE = new Date("2026-06-12T09:24:00")
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
|
||||
// Seeded PRNG (mulberry32) keeps the demo metrics deterministic across
|
||||
// renders and reloads while preserving the organic feel of live jitter.
|
||||
function createSeededRandom(seed: number) {
|
||||
let state = seed
|
||||
|
||||
return function nextRandom() {
|
||||
state = (state + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(state ^ (state >>> 15), state | 1)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const nextRandom = createSeededRandom(STATS_RANDOM_SEED)
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.round((min + nextRandom() * (max - min)) * 10) / 10
|
||||
}
|
||||
|
||||
function seedHistory(min: number, max: number): number[] {
|
||||
return Array.from({ length: HISTORY_LENGTH }, () => randomInRange(min, max))
|
||||
}
|
||||
|
||||
function smoothPath(pts: { x: number; y: number }[]): string {
|
||||
if (pts.length === 0) return ""
|
||||
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const curr = pts[i]
|
||||
const cx = (prev.x + curr.x) / 2
|
||||
d += ` C ${cx} ${prev.y}, ${cx} ${curr.y}, ${curr.x} ${curr.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ── Sparkline Area (full-width responsive) ──
|
||||
|
||||
function SparklineArea({
|
||||
data,
|
||||
max,
|
||||
color,
|
||||
spikeThreshold,
|
||||
height = 36,
|
||||
}: {
|
||||
data: number[]
|
||||
max: number
|
||||
color: string
|
||||
spikeThreshold: number
|
||||
height?: number
|
||||
}) {
|
||||
const gradientId = useId()
|
||||
const W = 100
|
||||
const H = height
|
||||
const currentValue = data[data.length - 1] ?? 0
|
||||
const hasSpike = currentValue > spikeThreshold
|
||||
const activeColor = hasSpike ? SPIKE_COLOR : color
|
||||
|
||||
const pts = data.map((value, i) => ({
|
||||
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
|
||||
y: H - (Math.min(value, max) / max) * (H - 2),
|
||||
}))
|
||||
|
||||
const linePath = smoothPath(pts)
|
||||
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
className="w-full"
|
||||
style={{ height: `${H}px` }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={activeColor} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={activeColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={activeColor}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mini Sparkline (for agent rows) ──
|
||||
|
||||
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
|
||||
const gradientId = useId()
|
||||
const W = 40
|
||||
const H = 14
|
||||
const dataMax = Math.max(...data, 1)
|
||||
|
||||
const pts = data.map((value, i) => ({
|
||||
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
|
||||
y: H - (value / dataMax) * (H - 1),
|
||||
}))
|
||||
|
||||
const linePath = smoothPath(pts)
|
||||
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width={W}
|
||||
height={H}
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Resource Card (grid cell) ──
|
||||
|
||||
function ResourceCard({
|
||||
metric,
|
||||
history,
|
||||
}: {
|
||||
metric: ResourceMetric
|
||||
history: number[]
|
||||
}) {
|
||||
const current = history[history.length - 1] ?? 0
|
||||
const max = metric.id === "memory" ? 16 : 100
|
||||
const isHigh = current > metric.spikeThreshold
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0 transition-colors duration-300"
|
||||
style={{
|
||||
backgroundColor: `${isHigh ? SPIKE_COLOR : metric.color}18`,
|
||||
}}
|
||||
>
|
||||
<ItemMedia
|
||||
variant="icon"
|
||||
className="size-auto"
|
||||
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 text-xs font-semibold tabular-nums transition-colors duration-300"
|
||||
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
|
||||
>
|
||||
{current}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<SparklineArea
|
||||
data={history}
|
||||
max={max}
|
||||
color={metric.color}
|
||||
spikeThreshold={metric.spikeThreshold}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Agent Memory Row ──
|
||||
|
||||
type AgentWithHistory = Agent & { history: number[] }
|
||||
|
||||
function AgentMemoryRow({ agent }: { agent: AgentWithHistory }) {
|
||||
const barPercent = Math.min(100, (agent.memoryMb / 512) * 100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">
|
||||
{agent.name}
|
||||
</span>
|
||||
<Progress
|
||||
value={barPercent}
|
||||
className="w-12 **:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ "--bar-color": agent.color } as CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground w-10 text-right text-[10px] tabular-nums">
|
||||
{agent.memoryMb}MB
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Status Badge ──
|
||||
|
||||
function StatusBadge({ spiking }: { spiking: boolean }) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-4 border-0 px-1.5 text-[10px] font-medium",
|
||||
spiking
|
||||
? "bg-destructive/10 text-destructive dark:bg-destructive/20"
|
||||
: "bg-success/10 text-success dark:bg-success/20"
|
||||
)}
|
||||
>
|
||||
{spiking ? "Alert" : "Normal"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ── System Stats ──
|
||||
|
||||
export function SystemStats() {
|
||||
const [histories, setHistories] = useState<Record<string, number[]>>(() => {
|
||||
const initial: Record<string, number[]> = {}
|
||||
for (const m of RESOURCE_METRICS) {
|
||||
initial[m.id] = seedHistory(m.seedRange[0], m.seedRange[1])
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const [agentData, setAgentData] = useState<AgentWithHistory[]>(() =>
|
||||
AGENTS.map((a) => ({
|
||||
...a,
|
||||
history: Array.from({ length: 10 }, () =>
|
||||
Math.max(32, Math.round(a.memoryMb + (nextRandom() - 0.5) * 80))
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
const [showAgents, setShowAgents] = useState(true)
|
||||
const [now, setNow] = useState(DEMO_REFERENCE_DATE)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
|
||||
|
||||
// Derived - no extra state needed
|
||||
const spiking = RESOURCE_METRICS.some((m) => {
|
||||
const history = histories[m.id] ?? []
|
||||
const current = history[history.length - 1] ?? 0
|
||||
return current > m.spikeThreshold
|
||||
})
|
||||
|
||||
const tick = useCallback(() => {
|
||||
setHistories((prev) => {
|
||||
const next: Record<string, number[]> = {}
|
||||
for (const m of RESOURCE_METRICS) {
|
||||
const old = prev[m.id] ?? []
|
||||
const jitter = randomInRange(
|
||||
m.seedRange[0] * 0.8,
|
||||
m.seedRange[1] * 1.15
|
||||
)
|
||||
const clamped =
|
||||
m.id === "memory"
|
||||
? Math.min(16, Math.max(0, jitter))
|
||||
: Math.min(100, Math.max(0, jitter))
|
||||
next[m.id] = [...old.slice(-(HISTORY_LENGTH - 1)), clamped]
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setAgentData((prev) =>
|
||||
prev.map((a) => {
|
||||
const newMb = Math.max(
|
||||
32,
|
||||
Math.round(a.memoryMb + (nextRandom() - 0.5) * 20)
|
||||
)
|
||||
return {
|
||||
...a,
|
||||
memoryMb: newMb,
|
||||
history: [...a.history.slice(-9), newMb],
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setNow((prev) => new Date(prev.getTime() + UPDATE_INTERVAL_MS))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(tick, UPDATE_INTERVAL_MS)
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [tick])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="System monitor"
|
||||
className={cn(
|
||||
"relative inline-flex h-7 items-center gap-1.5 px-2 transition-colors outline-none",
|
||||
"rounded-md",
|
||||
"border-border hover:bg-accent focus-visible:ring-ring border focus-visible:ring-2"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Icon with beep ring on spike */}
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<ActivityIcon className={cn(
|
||||
"size-3.5 transition-colors duration-300",
|
||||
spiking ? "text-destructive" : "text-muted-foreground"
|
||||
)} aria-hidden="true" />
|
||||
{spiking && (
|
||||
<span
|
||||
className="bg-destructive/25 absolute inset-0 animate-ping rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-medium">System</span>
|
||||
<StatusBadge spiking={spiking} />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-80 gap-0! space-y-0! p-0!"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
System Monitor
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{TIME_FORMATTER.format(now)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2×2 Resource Grid */}
|
||||
<div className="grid grid-cols-2">
|
||||
{RESOURCE_METRICS.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && "border-border border-l",
|
||||
i >= 2 && "border-border border-t"
|
||||
)}
|
||||
>
|
||||
<ResourceCard
|
||||
metric={metric}
|
||||
history={histories[metric.id] ?? []}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-Agent Memory */}
|
||||
<div className="border-border border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAgents((prev) => !prev)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors"
|
||||
aria-expanded={showAgents}
|
||||
aria-controls="agent-memory-panel"
|
||||
>
|
||||
<ChevronRightIcon className={cn(
|
||||
"size-3 shrink-0 transition-transform duration-200",
|
||||
showAgents && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
Per-Agent Memory
|
||||
</button>
|
||||
|
||||
{showAgents && (
|
||||
<div id="agent-memory-panel" className="pb-2">
|
||||
{agentData.map((agent) => (
|
||||
<AgentMemoryRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AppShell } from "./components/app-shell"
|
||||
|
||||
export function Page() {
|
||||
return <AppShell />
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from auth-3's WaveDots backdrop with the twinkle
|
||||
* animation removed: a dense grid of dots at a fixed per-dot brightness,
|
||||
* painted once and again on resize or theme change. The dot color resolves
|
||||
* from the canvas `color` token so it stays neutral in light and dark.
|
||||
* customize: GAP (density), DOT (size), PEAK (max brightness).
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3 // dot spacing in px (tight grid, still distinct dots)
|
||||
const DOT = 1.5 // dot side in px
|
||||
const BASE = 0.05 // dim end of a dot
|
||||
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
// Resetting width clears the canvas and restores the identity transform,
|
||||
// so the color probe below reads a raw device pixel.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
// Resolve the token to concrete sRGB by painting + reading it back, so
|
||||
// oklch never casts a color and dark mode recolors on theme change.
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
// Repaint when the theme class toggles so the resolved color stays correct.
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { CardDotField } from "./card-dot-field"
|
||||
import { ICard } from "./data"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<FramePanel className="isolate">
|
||||
{/* Card */}
|
||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
||||
<div className="relative z-10 space-y-7.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-11 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="block text-sm leading-tight font-medium">
|
||||
{card.title}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{card.link}
|
||||
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Recent Orders Overview",
|
||||
description:
|
||||
"Track and review all recent purchases, updates, and status changes in one place.",
|
||||
link: "View Orders",
|
||||
icon: (
|
||||
<ShoppingBagIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-green-600",
|
||||
},
|
||||
{
|
||||
title: "Active Opportunities Pipeline",
|
||||
description:
|
||||
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
|
||||
link: "Open Pipeline",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-indigo-600",
|
||||
},
|
||||
{
|
||||
title: "Performance & Sales Reports",
|
||||
description:
|
||||
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
|
||||
link: "View Reports",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-sky-600",
|
||||
},
|
||||
{
|
||||
title: "Integration Settings & Sync",
|
||||
description:
|
||||
"Manage connections with third-party tools and ensure data stays in sync.",
|
||||
link: "Manage Integrations",
|
||||
icon: (
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-orange-600",
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.label} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { ICard } from "./data"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
{card.link}
|
||||
</a>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
label: "Binance",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
||||
link: "https://www.binance.com/en/markets/over..",
|
||||
},
|
||||
{
|
||||
label: "Revenue",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Get instant insights into earnings and cash flow performance.",
|
||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
||||
},
|
||||
{
|
||||
label: "Shipments",
|
||||
icon: (
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
||||
link: "https://www.educare.io/platform/analytics/e..",
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type CSSProperties } from "react"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
chartConfig,
|
||||
inflowRanges,
|
||||
rangeOptions,
|
||||
type InflowSource,
|
||||
type RangeKey,
|
||||
} from "./data"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function GaugeGrid() {
|
||||
const gridPattern =
|
||||
"[background-image:linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [background-size:8px_8px]"
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-52 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-40 [mask-image:linear-gradient(to_bottom,black_0%,black_42%,transparent_100%)] opacity-35",
|
||||
gridPattern
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -inset-x-8 top-7 h-44 [mask-image:radial-gradient(ellipse_88%_76%_at_50%_16%,black_0%,black_46%,transparent_100%)] opacity-45 blur-md",
|
||||
gridPattern
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(to_bottom,transparent_0%,var(--card)_72%,var(--card)_100%)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeMenu({
|
||||
selectedRange,
|
||||
onSelectRange,
|
||||
}: {
|
||||
selectedRange: RangeKey
|
||||
onSelectRange: (range: RangeKey) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-24 justify-between gap-1.5 px-3"
|
||||
aria-label="Change inflow range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{inflowRanges[selectedRange].label}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
{rangeOptions.map((range) => (
|
||||
<DropdownMenuItem
|
||||
key={range.key}
|
||||
onClick={() => onSelectRange(range.key)}
|
||||
>
|
||||
{range.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: InflowSource }) {
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
"dark:border-border relative isolate flex size-8 items-center justify-center overflow-hidden border-2 border-white bg-[var(--source-icon-bg)] p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:bg-neutral-950 dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.35)] [&_svg]:size-4 [&_svg]:text-white",
|
||||
source.id !== "qbridge" && "dark:[--source-stipple-opacity:0.38]",
|
||||
source.id === "qbridge" && "dark:[--source-stipple-opacity:0.3]"
|
||||
)}
|
||||
style={{ "--source-icon-bg": source.color } as CSSProperties}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 [background-image:radial-gradient(circle_at_center,rgba(255,255,255,0.22)_0.6px,transparent_0.65px)] [background-size:3px_3px] opacity-[var(--source-stipple-opacity,0)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-[1] [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)]"
|
||||
/>
|
||||
<ItemMedia variant="icon" className="relative z-10 size-auto">
|
||||
{source.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceMetric({ source }: { source: InflowSource }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-2 px-2 text-center">
|
||||
<SourceIcon source={source} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground text-xs">{source.name}</div>
|
||||
<div className="text-base font-medium">{source.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
const [selectedRange, setSelectedRange] = useState<RangeKey>("week")
|
||||
const currentRange = inflowRanges[selectedRange]
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-[360px] gap-0 overflow-hidden py-0">
|
||||
{/* Header */}
|
||||
<CardHeader className="relative z-10 flex items-center justify-between border-b p-4!">
|
||||
<CardTitle className="text-sm font-medium">Capital Inflows</CardTitle>
|
||||
<CardAction>
|
||||
<RangeMenu
|
||||
selectedRange={selectedRange}
|
||||
onSelectRange={setSelectedRange}
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="relative p-0!">
|
||||
<GaugeGrid />
|
||||
|
||||
<div className="relative px-5 pt-5 pb-4">
|
||||
<div className="relative mx-auto h-[158px] w-full max-w-[300px]">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-full w-full overflow-visible"
|
||||
initialDimension={{ width: 300, height: 158 }}
|
||||
role="img"
|
||||
aria-label={`Capital inflows for ${currentRange.label}: ${currentRange.total}`}
|
||||
>
|
||||
<PieChart
|
||||
accessibilityLayer
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => (
|
||||
<>
|
||||
<span className="text-muted-foreground">{name}</span>
|
||||
<span className="font-mono font-medium tabular-nums">
|
||||
$
|
||||
{Number(value).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
M
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={currentRange.sources}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
cx="50%"
|
||||
cy="93%"
|
||||
innerRadius={84}
|
||||
outerRadius={116}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{currentRange.sources.map((source) => (
|
||||
<Cell key={source.id} fill={`var(--color-${source.id})`} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[82px] flex flex-col items-center">
|
||||
<span className="text-muted-foreground text-xs">Capital In</span>
|
||||
<span className="text-2xl font-medium">{currentRange.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
<Separator className="mb-5" />
|
||||
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
|
||||
{currentRange.sources.map((source, index) => (
|
||||
<div key={source.id} className="contents">
|
||||
<SourceMetric source={source} />
|
||||
{index < currentRange.sources.length - 1 ? (
|
||||
<Separator orientation="vertical" className="mx-2" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { ActivityIcon, SparklesIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export type RangeKey = "week" | "month" | "quarter"
|
||||
|
||||
export interface InflowSource {
|
||||
id: "wavemark" | "envato" | "qbridge"
|
||||
name: string
|
||||
amount: number
|
||||
value: string
|
||||
color: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export interface InflowRange {
|
||||
label: string
|
||||
total: string
|
||||
sources: InflowSource[]
|
||||
}
|
||||
|
||||
export const rangeOptions: { key: RangeKey; label: string }[] = [
|
||||
{ key: "week", label: "Last Week" },
|
||||
{ key: "month", label: "Last Month" },
|
||||
{ key: "quarter", label: "Last Quarter" },
|
||||
]
|
||||
|
||||
const sourceIcons = {
|
||||
wavemark: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
envato: (
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
),
|
||||
qbridge: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
} satisfies Record<InflowSource["id"], ReactNode>
|
||||
|
||||
export const inflowRanges: Record<RangeKey, InflowRange> = {
|
||||
week: {
|
||||
label: "Last Week",
|
||||
total: "$12.4M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 7.6,
|
||||
value: "$7.6M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 3.4,
|
||||
value: "$3.4M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 1.2,
|
||||
value: "$1.2M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
month: {
|
||||
label: "Last Month",
|
||||
total: "$47.8M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 28.4,
|
||||
value: "$28.4M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 13.6,
|
||||
value: "$13.6M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 5.8,
|
||||
value: "$5.8M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
quarter: {
|
||||
label: "Last Quarter",
|
||||
total: "$138.2M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 79.1,
|
||||
value: "$79.1M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 42.5,
|
||||
value: "$42.5M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 16.6,
|
||||
value: "$16.6M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const chartConfig = {
|
||||
wavemark: {
|
||||
label: "WaveMark",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
},
|
||||
envato: {
|
||||
label: "Envato Inc.",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
},
|
||||
qbridge: {
|
||||
label: "QBridge B.V.",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
chartConfig,
|
||||
inflowPeriods,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes chart-13-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-13-reveal-up {
|
||||
animation: chart-13-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-13-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Other Sources",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Capital Inflows"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked capital committed during the selected period.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="chart-13-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Capital Inflows: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={chartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Capital</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container w-full max-w-[29rem]">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Capital Inflows</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
|
||||
export type InflowFundKey = "northline" | "copper" | "bridgewell" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const teal = "oklch(0.6 0.145 181.2)"
|
||||
const amber = "oklch(0.76 0.161 80.1)"
|
||||
const orange = "oklch(0.66 0.19 42.8)"
|
||||
|
||||
export const chartConfig = {
|
||||
capital: {
|
||||
label: "Capital",
|
||||
},
|
||||
northline: {
|
||||
label: "WaveMark Capital",
|
||||
color: teal,
|
||||
},
|
||||
copper: {
|
||||
label: "Copper Market",
|
||||
color: amber,
|
||||
},
|
||||
bridgewell: {
|
||||
label: "Bridgewell Fund",
|
||||
color: orange,
|
||||
},
|
||||
reserve: {
|
||||
label: "Other Sources",
|
||||
color: "var(--muted)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "$12,4M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "WaveMark Capital",
|
||||
amount: "$7,6M",
|
||||
share: 35,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Envato Market",
|
||||
amount: "$3,4M",
|
||||
share: 34,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "QBridge Investment",
|
||||
amount: "$1,2M",
|
||||
share: 14,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "$48,2M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "Northline Capital",
|
||||
amount: "$21,8M",
|
||||
share: 39,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Copper Market",
|
||||
amount: "$16,5M",
|
||||
share: 31,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "Bridgewell Fund",
|
||||
amount: "$6,9M",
|
||||
share: 18,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "$186,7M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "Northline Capital",
|
||||
amount: "$72,4M",
|
||||
share: 37,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Copper Market",
|
||||
amount: "$61,2M",
|
||||
share: 33,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "Bridgewell Fund",
|
||||
amount: "$29,6M",
|
||||
share: 16,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
import { activeUsersData, customersData, revenueData } from "./data"
|
||||
import { CircleDollarSignIcon, UserPlusIcon, TrendingUp } from "lucide-react"
|
||||
|
||||
// ── Business metric cards ──
|
||||
|
||||
const businessCards = [
|
||||
{
|
||||
title: "Revenue",
|
||||
period: "Last 28 days",
|
||||
value: "6.238$",
|
||||
timestamp: "",
|
||||
data: revenueData,
|
||||
color: "var(--color-emerald-500)",
|
||||
gradientId: "revenueGradient",
|
||||
icon: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "New Customers",
|
||||
period: "Last 28 days",
|
||||
value: "6.202",
|
||||
timestamp: "3h ago",
|
||||
data: customersData,
|
||||
color: "var(--color-blue-500)",
|
||||
gradientId: "customersGradient",
|
||||
icon: (
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Active Users",
|
||||
period: "Last 28 days",
|
||||
value: "18.945",
|
||||
timestamp: "1h ago",
|
||||
data: activeUsersData,
|
||||
color: "var(--color-violet-500)",
|
||||
gradientId: "usersGradient",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container w-full max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
{businessCards.map((card, i) => (
|
||||
<Frame key={i} variant="ghost">
|
||||
<FramePanel className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: card.color }} className="[&_svg]:size-5">
|
||||
{card.icon}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{card.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex items-end justify-between gap-2.5">
|
||||
{/* Value */}
|
||||
<div className="flex flex-col gap-px pb-2">
|
||||
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{card.period}
|
||||
</div>
|
||||
<div className="text-foreground text-xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-16 w-full max-w-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={card.data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={card.gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: card.color,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "2 2",
|
||||
}}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value as number
|
||||
const formatValue = (val: number) => {
|
||||
if (card.title === "Revenue") {
|
||||
return `${(val / 1000).toFixed(1)}k US$`
|
||||
}
|
||||
return `${(val / 1000).toFixed(1)}k`
|
||||
}
|
||||
return (
|
||||
<div className="bg-popover border-border pointer-events-none rounded-md border p-2 shadow-lg backdrop-blur-sm">
|
||||
<p className="text-popover-foreground text-sm font-semibold">
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={card.color}
|
||||
fill={`url(#${card.gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: card.color,
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface MetricCard {
|
||||
title: string
|
||||
period: string
|
||||
value: string
|
||||
timestamp: string
|
||||
data: { value: number }[]
|
||||
color: string
|
||||
icon: ReactNode
|
||||
gradientId: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const revenueData = [
|
||||
{ value: 1000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 6100 },
|
||||
{ value: 3000 },
|
||||
{ value: 6800 },
|
||||
{ value: 2000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 6238 },
|
||||
]
|
||||
|
||||
export const customersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 5100 },
|
||||
{ value: 2500 },
|
||||
{ value: 6800 },
|
||||
{ value: 1800 },
|
||||
{ value: 1000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 2700 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export const activeUsersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 3500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1200 },
|
||||
{ value: 4100 },
|
||||
{ value: 3500 },
|
||||
{ value: 5800 },
|
||||
{ value: 2000 },
|
||||
{ value: 800 },
|
||||
{ value: 3000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-10 md:p-20">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { LabelList, Pie, PieChart } from "recharts"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { ChartContainer, ChartTooltip } from "@evobgp/ui/components/chart"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
|
||||
import { browserData, chartConfig, PeriodKey, PERIODS } from "./data"
|
||||
import { TrendingUp, PieChartIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
// ── Custom tooltip ──
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: {
|
||||
name: string
|
||||
color: string
|
||||
value: number
|
||||
payload: { browser: string }
|
||||
}[]
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-popover min-w-[150px] rounded-lg border p-4 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-popover-foreground border-border/50 mb-3 border-b pb-2 text-sm font-semibold tracking-wider uppercase">
|
||||
{payload[0].payload.browser}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-sm"
|
||||
style={{ backgroundColor: payload[0].color }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Visitors
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-popover-foreground text-sm font-semibold">
|
||||
{payload[0].value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodKey>("5D")
|
||||
|
||||
const { currentData, totalVisitors } = useMemo(() => {
|
||||
const data = browserData[selectedPeriod] || []
|
||||
const total = data.reduce((sum, item) => sum + item.visitors, 0)
|
||||
return { currentData: data, totalVisitors: total }
|
||||
}, [selectedPeriod])
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-md">
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-6 p-6!">
|
||||
<div className="flex items-center justify-between border-b border-dashed pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold">Browser Usage</h3>
|
||||
<Badge variant="success-light" className="gap-1 border-none">
|
||||
<TrendingUp className="size-3.5" aria-hidden="true" />
|
||||
<span>+5.2%</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<Item className="bg-primary/5 text-primary flex size-12 items-center justify-center rounded-full p-0 [&_svg]:h-6 [&_svg]:w-6">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<PieChartIcon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
Total Visitors
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalVisitors.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={selectedPeriod}
|
||||
onValueChange={(value) => setSelectedPeriod(value as PeriodKey)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{Object.values(PERIODS).map((period) => (
|
||||
<TabsTrigger
|
||||
key={period.key}
|
||||
value={period.key}
|
||||
className="flex-1"
|
||||
>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mx-auto aspect-square max-h-[200px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-visible"
|
||||
>
|
||||
<PieChart className="overflow-visible">
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Pie
|
||||
data={currentData}
|
||||
innerRadius={35}
|
||||
outerRadius={100}
|
||||
dataKey="visitors"
|
||||
nameKey="browser"
|
||||
strokeWidth={4}
|
||||
stroke="var(--background)"
|
||||
cornerRadius={8}
|
||||
paddingAngle={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="visitors"
|
||||
stroke="none"
|
||||
fontSize={10}
|
||||
fontWeight={600}
|
||||
fill="white"
|
||||
formatter={(value: number) =>
|
||||
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value
|
||||
}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-2 text-xs">
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span>Visitor data based on unique browser signatures.</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ChartConfig } from "@evobgp/ui/components/chart"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type PeriodKey = "5D" | "2W" | "1M"
|
||||
|
||||
// ── Config ──
|
||||
|
||||
export const chartConfig = {
|
||||
visitors: { label: "Visitors" },
|
||||
chrome: { label: "Chrome", color: "var(--color-blue-500)" },
|
||||
safari: { label: "Safari", color: "var(--color-sky-500)" },
|
||||
firefox: { label: "Firefox", color: "var(--color-orange-500)" },
|
||||
edge: { label: "Edge", color: "var(--color-indigo-500)" },
|
||||
other: { label: "Other", color: "var(--color-slate-500)" },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const PERIODS = {
|
||||
"5D": { key: "5D", label: "5D" },
|
||||
"2W": { key: "2W", label: "2W" },
|
||||
"1M": { key: "1M", label: "1M" },
|
||||
} as const
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const browserData: Record<
|
||||
PeriodKey,
|
||||
{ browser: string; visitors: number; fill: string }[]
|
||||
> = {
|
||||
"5D": [
|
||||
{ browser: "chrome", visitors: 275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 200, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 187, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 173, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 90, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
"2W": [
|
||||
{ browser: "chrome", visitors: 1275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 800, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 587, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 473, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 290, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
"1M": [
|
||||
{ browser: "chrome", visitors: 4275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 3200, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 2187, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 1873, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 1090, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
allocationMemberCount,
|
||||
allocationMembers,
|
||||
allocationPeriods,
|
||||
inflowChartConfig,
|
||||
inflowPeriods,
|
||||
SEGMENT_COUNT,
|
||||
type AllocationPeriod,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes dashboard-1-flow-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
||||
className="flex h-7 w-full items-stretch justify-between"
|
||||
role="img"
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
key={segment}
|
||||
className={cn(
|
||||
"h-full w-1 shrink-0 rounded-full",
|
||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberStack() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup className="-space-x-2">
|
||||
{allocationMembers.map((member) => (
|
||||
<Avatar key={member.name} className="size-6">
|
||||
{member.avatar ? (
|
||||
<AvatarImage src={member.avatar} alt={member.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-background text-xs font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{allocationMemberCount} Members
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllocationChart() {
|
||||
return (
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel>
|
||||
<Tabs
|
||||
defaultValue={allocationPeriods[0].value}
|
||||
className="h-full w-full min-w-0 gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Capacity Allocation info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
||||
>
|
||||
Fulfillment capacity by selected period.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<TabsList>
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Metric */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[26px] font-medium">
|
||||
{period.allocation}
|
||||
</span>
|
||||
<span className="text-success text-xs font-medium">
|
||||
{period.delta}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{period.comparison}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<AllocationMeter period={period} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Queued Orders:
|
||||
</span>{" "}
|
||||
<span className="text-sm font-medium">
|
||||
{period.exposure}
|
||||
</span>
|
||||
</p>
|
||||
<MemberStack />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Reserve Capacity",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Decision Flow"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={inflowChartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 30 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowChart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
||||
<AllocationChart />
|
||||
<InflowChart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
||||
|
||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
{/* Heading */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xl font-medium tracking-tight">
|
||||
{card.balance}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{card.change.percent} ({card.change.amount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{FULFILLMENT_CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
PERFORMANCE_RANGE_OPTIONS,
|
||||
SHIFT_ACTIVITY,
|
||||
SHIFT_PERFORMANCE,
|
||||
SHIFT_PIPELINE_PROGRESS,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function InvestorCard() {
|
||||
return (
|
||||
<Frame className="h-full w-full">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHIFT_PERFORMANCE.map((item) => (
|
||||
<div
|
||||
className="flex flex-col items-start justify-start"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="text-foreground text-xl font-bold">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
||||
item.trend === "positive"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trend === "positive" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Pipeline Progress
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{SHIFT_PIPELINE_PROGRESS}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={SHIFT_PIPELINE_PROGRESS}
|
||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{SHIFT_ACTIVITY.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleCheckIcon className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
activity.tone === "success" && "text-emerald-500",
|
||||
activity.tone === "info" && "text-sky-500",
|
||||
activity.tone === "warning" && "text-amber-500"
|
||||
)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{activity.title}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activity.tone === "success"
|
||||
? "success-light"
|
||||
: activity.tone === "info"
|
||||
? "info-light"
|
||||
: "warning-light"
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
{/* Footer */}
|
||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
||||
<Button variant="outline" className="flex-1">
|
||||
Schedule
|
||||
</Button>
|
||||
<Button className="flex-1">Full Report</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Chart as CapacityChart } from "./capacity-chart"
|
||||
import { Chart as ChartCards } from "./chart-cards"
|
||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
||||
import { ExceptionGrid } from "./exception-grid"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<Navbar />
|
||||
|
||||
<section aria-label="Fulfillment metrics">
|
||||
<ChartCards />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Fulfillment operations"
|
||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-w-0">
|
||||
<CommanderCard />
|
||||
</div>
|
||||
<div className="flex min-w-0">
|
||||
<CapacityChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Fulfillment exception queue">
|
||||
<ExceptionGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
||||
|
||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
||||
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface FulfillmentException {
|
||||
id: string
|
||||
reference: string
|
||||
customer: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
lane: string
|
||||
facility: string
|
||||
stage: string
|
||||
promise: string
|
||||
slaMinutes: number
|
||||
automation: AutomationLevel
|
||||
owner: string
|
||||
units: number
|
||||
value: number
|
||||
risk: string
|
||||
status: FulfillmentStatus
|
||||
}
|
||||
|
||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
||||
"On Time",
|
||||
"At Risk",
|
||||
"Delayed",
|
||||
"Blocked",
|
||||
]
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<
|
||||
FulfillmentStatus,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
"On Time": "success-outline",
|
||||
"At Risk": "warning-outline",
|
||||
Delayed: "info-outline",
|
||||
Blocked: "destructive-outline",
|
||||
}
|
||||
|
||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
||||
AutomationLevel,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
Autopilot: "success-light",
|
||||
Copilot: "info-light",
|
||||
Manual: "warning-light",
|
||||
}
|
||||
|
||||
export const NAV_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
name: "Maya Singh",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Fulfillment lead",
|
||||
},
|
||||
{
|
||||
name: "Leo Martins",
|
||||
initials: "LM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Automation owner",
|
||||
},
|
||||
{
|
||||
name: "Nora Albright",
|
||||
initials: "NA",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Capacity planner",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
||||
src: member.avatar,
|
||||
initials: member.initials,
|
||||
name: member.name,
|
||||
}))
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 11
|
||||
|
||||
export interface FulfillmentCardChange {
|
||||
positive: boolean
|
||||
percent: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface FulfillmentCard {
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
balance: string
|
||||
change: FulfillmentCardChange
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
||||
{
|
||||
typeLabel: "Outbound",
|
||||
title: "Orders Ready",
|
||||
metricLabel: "Ready Volume",
|
||||
balance: "18,420",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+11.8%",
|
||||
amount: "1,946",
|
||||
},
|
||||
iconBg: "bg-neutral-950",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Promise",
|
||||
title: "Same-Day SLA",
|
||||
metricLabel: "Service Level",
|
||||
balance: "94.8%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+1.2 pts",
|
||||
amount: "shift",
|
||||
},
|
||||
iconBg: "bg-indigo-600",
|
||||
icon: (
|
||||
<TruckIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Inventory",
|
||||
title: "Stock Risk",
|
||||
metricLabel: "Blocked SKUs",
|
||||
balance: "31",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "13 fewer",
|
||||
amount: "since 06:00",
|
||||
},
|
||||
iconBg: "bg-amber-400",
|
||||
icon: (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Policy",
|
||||
title: "AI Autopilot",
|
||||
metricLabel: "Auto Resolved",
|
||||
balance: "71.6%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+8.4 pts",
|
||||
amount: "policy",
|
||||
},
|
||||
iconBg: "bg-cyan-600",
|
||||
icon: (
|
||||
<BotIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type AllocationPeriod = {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
allocation: string
|
||||
delta: string
|
||||
comparison: string
|
||||
exposure: string
|
||||
filledSegments: number
|
||||
}
|
||||
|
||||
export type AllocationMember = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const SEGMENT_COUNT = 56
|
||||
export const allocationMemberCount = 6
|
||||
|
||||
export const allocationPeriods: AllocationPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
allocation: "86%",
|
||||
delta: "+5.8%",
|
||||
comparison: "vs labor plan",
|
||||
exposure: "3,840 orders",
|
||||
filledSegments: 48,
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
allocation: "79%",
|
||||
delta: "+2.4%",
|
||||
comparison: "vs prior month",
|
||||
exposure: "18 priority lanes",
|
||||
filledSegments: 44,
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
allocation: "74%",
|
||||
delta: "+9.2%",
|
||||
comparison: "automation lift",
|
||||
exposure: "6 facilities",
|
||||
filledSegments: 41,
|
||||
},
|
||||
]
|
||||
|
||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
||||
(member) => ({
|
||||
name: member.name,
|
||||
initials: member.initials,
|
||||
avatar: member.src,
|
||||
})
|
||||
)
|
||||
|
||||
export type PerformanceTrend = "positive" | "negative"
|
||||
export type ActivityTone = "success" | "info" | "warning"
|
||||
|
||||
export interface PerformanceMetric {
|
||||
label: string
|
||||
value: string
|
||||
trend: PerformanceTrend
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface ShiftActivity {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
status: string
|
||||
tone: ActivityTone
|
||||
}
|
||||
|
||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Week", value: "week" },
|
||||
{ label: "Month", value: "month" },
|
||||
]
|
||||
|
||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
||||
{
|
||||
label: "Orders Cleared",
|
||||
value: "18.4k",
|
||||
trend: "positive",
|
||||
delta: "+11.8%",
|
||||
},
|
||||
{
|
||||
label: "SLA Recovery",
|
||||
value: "94.8%",
|
||||
trend: "positive",
|
||||
delta: "+1.2 pts",
|
||||
},
|
||||
{
|
||||
label: "Risk Exposure",
|
||||
value: "$128k",
|
||||
trend: "negative",
|
||||
delta: "-9.4%",
|
||||
},
|
||||
]
|
||||
|
||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
||||
|
||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
||||
{
|
||||
id: "wave-release",
|
||||
title: "Released priority wave to dock B",
|
||||
time: "4 min ago",
|
||||
status: "Cleared",
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
id: "carrier-reprice",
|
||||
title: "Carrier mix repriced for zone 6",
|
||||
time: "12 min ago",
|
||||
status: "Review",
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "inventory-hold",
|
||||
title: "Inventory hold isolated to 3 SKUs",
|
||||
time: "23 min ago",
|
||||
status: "Watch",
|
||||
tone: "warning",
|
||||
},
|
||||
]
|
||||
|
||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
headline: string
|
||||
description: string
|
||||
delta: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
||||
|
||||
export const inflowChartConfig = {
|
||||
flow: {
|
||||
label: "Flow",
|
||||
},
|
||||
autopilot: {
|
||||
label: "Autopilot",
|
||||
color: inflowAutopilotColor,
|
||||
},
|
||||
copilot: {
|
||||
label: "Copilot",
|
||||
color: inflowCopilotColor,
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: inflowManualColor,
|
||||
},
|
||||
reserve: {
|
||||
label: "Reserve",
|
||||
color: "oklch(0.7 0.04 260)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "18.4k",
|
||||
headline: "Exception Flow",
|
||||
description: "Orders entering decision lanes",
|
||||
delta: "+6.2%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "9.1k",
|
||||
share: 49.5,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "5.2k",
|
||||
share: 28.3,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "2.8k",
|
||||
share: 15.2,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "76.8k",
|
||||
headline: "Resolved Flow",
|
||||
description: "Completed decisions this month",
|
||||
delta: "+14.8%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "41.6k",
|
||||
share: 54.2,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "20.3k",
|
||||
share: 26.4,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "9.8k",
|
||||
share: 12.8,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "812k",
|
||||
headline: "Network Flow",
|
||||
description: "Decisions across six facilities",
|
||||
delta: "+21.5%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "428k",
|
||||
share: 52.7,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "224k",
|
||||
share: 27.6,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "103k",
|
||||
share: 12.7,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
||||
{
|
||||
id: "row-1001",
|
||||
reference: "NSC-84721",
|
||||
customer: "Avery Outdoor",
|
||||
email: "ops@averyoutdoor.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AO",
|
||||
lane: "Chicago to Austin",
|
||||
facility: "ORD-2",
|
||||
stage: "Carrier tender",
|
||||
promise: "Today 18:00",
|
||||
slaMinutes: 42,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 480,
|
||||
value: 38240,
|
||||
risk: "Carrier capacity is tight after midday cutoff",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1002",
|
||||
reference: "NSC-84734",
|
||||
customer: "Field & Frame",
|
||||
email: "priority@fieldframe.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "FF",
|
||||
lane: "Dallas to Phoenix",
|
||||
facility: "DFW-1",
|
||||
stage: "Pick wave",
|
||||
promise: "Today 16:30",
|
||||
slaMinutes: 88,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 310,
|
||||
value: 21480,
|
||||
risk: "Wave optimized by carton density",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1003",
|
||||
reference: "NSC-84755",
|
||||
customer: "MetroFit Labs",
|
||||
email: "ops@metrofit.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
|
||||
initials: "ML",
|
||||
lane: "Newark to Boston",
|
||||
facility: "EWR-3",
|
||||
stage: "Inventory hold",
|
||||
promise: "Today 15:15",
|
||||
slaMinutes: -24,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 126,
|
||||
value: 18760,
|
||||
risk: "Lot trace requires human release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1004",
|
||||
reference: "NSC-84763",
|
||||
customer: "Northline Studio",
|
||||
email: "returns@northline.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NS",
|
||||
lane: "Los Angeles to Seattle",
|
||||
facility: "LAX-4",
|
||||
stage: "Packing",
|
||||
promise: "Today 19:45",
|
||||
slaMinutes: 114,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 840,
|
||||
value: 52210,
|
||||
risk: "Packing line is running above plan",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1005",
|
||||
reference: "NSC-84801",
|
||||
customer: "Urban Pantry",
|
||||
email: "supply@urbanpantry.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
|
||||
initials: "UP",
|
||||
lane: "Atlanta to Miami",
|
||||
facility: "ATL-2",
|
||||
stage: "Cold chain",
|
||||
promise: "Today 17:00",
|
||||
slaMinutes: 9,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 212,
|
||||
value: 30440,
|
||||
risk: "Reefer handoff needs confirmation",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1006",
|
||||
reference: "NSC-84819",
|
||||
customer: "Glow Market",
|
||||
email: "vip@glowmarket.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "GM",
|
||||
lane: "Las Vegas to Denver",
|
||||
facility: "LAS-1",
|
||||
stage: "Labeling",
|
||||
promise: "Tomorrow 09:20",
|
||||
slaMinutes: 312,
|
||||
automation: "Autopilot",
|
||||
owner: "Nora Albright",
|
||||
units: 94,
|
||||
value: 10920,
|
||||
risk: "No current risk",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1007",
|
||||
reference: "NSC-84827",
|
||||
customer: "Ridge Supply",
|
||||
email: "buyers@ridgesupply.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "RS",
|
||||
lane: "Portland to San Jose",
|
||||
facility: "PDX-1",
|
||||
stage: "Split shipment",
|
||||
promise: "Today 20:00",
|
||||
slaMinutes: 36,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 176,
|
||||
value: 14680,
|
||||
risk: "Two SKUs short at primary node",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1008",
|
||||
reference: "NSC-84842",
|
||||
customer: "Casa Verde",
|
||||
email: "storeops@casaverde.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "CV",
|
||||
lane: "Nashville to Charlotte",
|
||||
facility: "BNA-2",
|
||||
stage: "Dock queue",
|
||||
promise: "Today 14:30",
|
||||
slaMinutes: -51,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 265,
|
||||
value: 22750,
|
||||
risk: "Outbound door is constrained",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1009",
|
||||
reference: "NSC-84864",
|
||||
customer: "Beacon Cycle",
|
||||
email: "logistics@beaconcycle.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
|
||||
initials: "BC",
|
||||
lane: "Columbus to Pittsburgh",
|
||||
facility: "CMH-1",
|
||||
stage: "Fraud review",
|
||||
promise: "Tomorrow 11:45",
|
||||
slaMinutes: 510,
|
||||
automation: "Manual",
|
||||
owner: "Maya Singh",
|
||||
units: 58,
|
||||
value: 8920,
|
||||
risk: "Payment review blocks release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1010",
|
||||
reference: "NSC-84888",
|
||||
customer: "Aster Goods",
|
||||
email: "ops@astergoods.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AG",
|
||||
lane: "Reno to Salt Lake City",
|
||||
facility: "RNO-1",
|
||||
stage: "Manifest",
|
||||
promise: "Today 22:15",
|
||||
slaMinutes: 177,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 390,
|
||||
value: 19340,
|
||||
risk: "Manifest is ready for carrier scan",
|
||||
status: "On Time",
|
||||
},
|
||||
]
|
||||
|
||||
export function fulfillmentSearchBlob(row: FulfillmentException): string {
|
||||
return [
|
||||
row.reference,
|
||||
row.customer,
|
||||
row.email,
|
||||
row.lane,
|
||||
row.facility,
|
||||
row.stage,
|
||||
row.promise,
|
||||
row.automation,
|
||||
row.owner,
|
||||
row.risk,
|
||||
row.status,
|
||||
String(row.units),
|
||||
String(row.value),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import { memo } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
AUTOMATION_BADGE_VARIANT,
|
||||
STATUS_BADGE_VARIANT,
|
||||
type AutomationLevel,
|
||||
type FulfillmentException,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
const currencyCompact = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const numberCompact = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const availabilityColor: Record<FulfillmentStatus, string> = {
|
||||
"On Time": "bg-success",
|
||||
"At Risk": "bg-warning",
|
||||
Delayed: "bg-info",
|
||||
Blocked: "bg-destructive",
|
||||
}
|
||||
|
||||
const stageProgress: Record<string, number> = {
|
||||
"Carrier tender": 72,
|
||||
"Pick wave": 64,
|
||||
"Inventory hold": 28,
|
||||
Packing: 82,
|
||||
"Cold chain": 48,
|
||||
Labeling: 76,
|
||||
"Split shipment": 39,
|
||||
"Dock queue": 31,
|
||||
"Fraud review": 24,
|
||||
Manifest: 90,
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: FulfillmentStatus
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", availabilityColor[status])}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
function AutomationBadge({ level }: { level: AutomationLevel }) {
|
||||
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
|
||||
}
|
||||
|
||||
const ReferenceCell = memo(function ReferenceCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View order ${order.reference}`}
|
||||
>
|
||||
{order.reference}
|
||||
</a>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{order.facility}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{order.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const CustomerCell = memo(function CustomerCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={order.avatar} alt={order.customer} />
|
||||
<AvatarFallback>{order.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View customer ${order.customer}`}
|
||||
>
|
||||
{order.customer}
|
||||
</a>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={order.email}
|
||||
>
|
||||
{order.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const StageCell = memo(function StageCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
const progress = stageProgress[order.stage] ?? 50
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
|
||||
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
|
||||
<PackageIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{order.stage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const LaneCell = memo(function LaneCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-foreground block max-w-full min-w-0 truncate font-medium"
|
||||
title={order.lane}
|
||||
>
|
||||
{order.lane}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
|
||||
title={order.facility}
|
||||
>
|
||||
{order.facility}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ValueCell = memo(function ValueCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const valueHint =
|
||||
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{currencyCompact.format(row.original.value)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
|
||||
<div className="flex flex-col">
|
||||
<span>{valueHint}</span>
|
||||
<span className="text-background/80">
|
||||
{numberCompact.format(row.original.units)} units
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function StateCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const sla = row.original.slaMinutes
|
||||
const slaHint =
|
||||
sla < 0
|
||||
? `${Math.abs(sla)} min overdue`
|
||||
: sla <= 45
|
||||
? `${sla} min buffer`
|
||||
: `Due ${row.original.promise}`
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<StatusBadge status={row.original.status} />
|
||||
<span className="text-muted-foreground max-w-full truncate text-xs">
|
||||
{slaHint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
|
||||
{row.original.risk}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const copyReference = async () => {
|
||||
await navigator.clipboard?.writeText(row.original.reference)
|
||||
toast.success("Reference copied", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Actions for ${row.original.reference}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Opening order", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View order
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Owner notified", {
|
||||
description: row.original.owner,
|
||||
})
|
||||
}
|
||||
>
|
||||
<BellIcon className="size-4" aria-hidden="true" />
|
||||
Notify owner
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyReference}>
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
Copy reference
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
toast.warning("Escalation staged", {
|
||||
description: "Connect this action to your incident workflow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Escalate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<FulfillmentException>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 35,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
id: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ReferenceCell row={row} />,
|
||||
size: 138,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Order",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer",
|
||||
id: "customer",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <CustomerCell row={row} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 190,
|
||||
meta: {
|
||||
headerTitle: "Customer",
|
||||
autoSize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lane",
|
||||
id: "lane",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <LaneCell row={row} />,
|
||||
size: 165,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Lane",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "stage",
|
||||
id: "stage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StageCell row={row} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Stage",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "automation",
|
||||
id: "automation",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
|
||||
size: 112,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Automation",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
id: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Value",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <RiskCell row={row} />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Risk",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell row={row} />,
|
||||
size: 142,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "State",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 46,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
]
|
||||
@@ -1,369 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
FULFILLMENT_ROWS,
|
||||
fulfillmentSearchBlob,
|
||||
STATUS_ORDER,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { columns, StatusBadge } from "./exception-columns"
|
||||
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedStatuses: FulfillmentStatus[]
|
||||
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
|
||||
onClearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
statusCounts: Record<string, number>
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedStatuses,
|
||||
onStatusChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
statusCounts,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full min-w-52 sm:w-60">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
aria-label="Search orders"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Filter by order status">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 && (
|
||||
<Badge variant="info-outline">
|
||||
{selectedStatuses.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="flex w-48 flex-col gap-2.5 p-3"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Filter by status
|
||||
</span>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(checked) =>
|
||||
onStatusChange(checked === true, status)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{statusCounts[status] ?? 0}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Exception queue actions">
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
Actions
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.success("Export ready", {
|
||||
description: "Exception queue export prepared.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Queue refreshed", {
|
||||
description: "Live data would refresh through your API.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("View settings", {
|
||||
description: "Column and density controls are available.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
View settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExceptionGrid() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "value", desc: true },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
|
||||
[]
|
||||
)
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((column) => column.id as string)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
risk: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
FULFILLMENT_ROWS.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.status] = (acc[row.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return FULFILLMENT_ROWS.filter((row) => {
|
||||
const matchesStatus =
|
||||
!selectedStatuses.length || selectedStatuses.includes(row.status)
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
}, [searchQuery, selectedStatuses])
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
||||
|
||||
const resetToFirstPage = () => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
|
||||
setSelectedStatuses((current) =>
|
||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
||||
)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedStatuses([])
|
||||
setSearchQuery("")
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
|
||||
columnResizeMode: "onChange",
|
||||
enableRowSelection: true,
|
||||
autoResetPageIndex: false,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
<ReuiDataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0
|
||||
? "No fulfillment exceptions match your filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "[&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
|
||||
records
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Create exception", {
|
||||
description:
|
||||
"Connect this button to your incident intake flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
<div className="px-4 py-3">
|
||||
<Toolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</ReuiDataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Header action controls reused from the solution-agents-8 report toolbar.
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Fulfillment command report is being prepared.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Header presence controls with team avatars and invite action.
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[9px]! font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount className="text-[10px]! font-medium">
|
||||
+{TEAM_EXTRA_COUNT}
|
||||
</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Invite team member"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
// Navbar with breadcrumb and report range actions.
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Fulfillment command header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user