Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c7152a285 | ||
|
|
a37c931ee7 |
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
name: shadcn-react
|
|
||||||
description: Управление shadcn/ui (React) + ReUI компонентами в EvoBGP — registry, CLI, импорты, матрица выбора. Использовать при любых UI-задачах в apps/web/ или packages/ui/ (новые экраны, компоненты, формы, data-grid, filters и др.).
|
|
||||||
---
|
|
||||||
|
|
||||||
# shadcn/ui (React) + ReUI в EvoBGP
|
|
||||||
|
|
||||||
EvoBGP использует **React 19 + shadcn/ui (base-nova) + ReUI** (`style: base-nova`, не Radix). Источники правды: MCP `plugin-shadcn-shadcn` + [ui.shadcn.com/docs](https://ui.shadcn.com/docs/components) + [reui.io/docs](https://reui.io/docs/components/base/).
|
|
||||||
|
|
||||||
См. `.cursor/rules/web-shadcn.mdc` (основные правила) и `.cursor/rules/context7-stack.mdc` (Context7 ID стека).
|
|
||||||
|
|
||||||
## Порядок UI-задачи (строго)
|
|
||||||
|
|
||||||
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки.
|
|
||||||
1. **MCP `plugin-shadcn-shadcn`** — `get_project_registries` (должны быть `@shadcn` и `@reui`).
|
|
||||||
2. **`search_items_in_registries`** — компонент/block/example:
|
|
||||||
- shadcn primitives/blocks → omit `registries` или `["@shadcn"]`
|
|
||||||
- Data Grid, Filters, Stepper, Kanban, Autocomplete и др. → `registries: ["@reui"]`
|
|
||||||
3. **`get_item_examples_from_registries`** — полный код примера перед JSX.
|
|
||||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`.
|
|
||||||
5. Выполнить add **из `apps/web`** (не из корня монорепо, не из `packages/ui`).
|
|
||||||
6. **CLI docs (обязательно):**
|
|
||||||
- `@shadcn/*` → `cd apps/web && pnpm dlx shadcn@latest docs <component>` — [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
|
||||||
- `@reui/*` → [ReUI docs](https://reui.io/docs/components/base/<name>) + [llms.txt](https://reui.io/llms.txt)
|
|
||||||
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения.
|
|
||||||
8. Адаптировать под TanStack Router / Query → `apps/web/src/`.
|
|
||||||
9. **`get_audit_checklist`** — перед merge PR.
|
|
||||||
|
|
||||||
## Размещение и импорты
|
|
||||||
|
|
||||||
| Слой | Путь | Импорт |
|
|
||||||
|------|------|--------|
|
|
||||||
| shadcn primitives | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
|
||||||
| Проектные обёртки | `apps/web/src/components/` | `@/components/<name>` |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/web
|
|
||||||
pnpm dlx shadcn@latest add button # @shadcn primitive → packages/ui/src/components/
|
|
||||||
pnpm dlx shadcn@latest add @reui/data-grid # ReUI enterprise → apps/web/src/components/reui/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Матрица выбора shadcn vs ReUI
|
|
||||||
|
|
||||||
| Задача | Registry | Импорт |
|
|
||||||
|--------|----------|--------|
|
|
||||||
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@evobgp/ui/components/*` |
|
|
||||||
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
|
||||||
| Data Grid (sort, pagination, virtual) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
|
||||||
| Мультифильтры | `@reui` | `@/components/reui/filters` |
|
|
||||||
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
|
||||||
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
|
||||||
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
|
||||||
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
|
||||||
|
|
||||||
**Простые списки** — shadcn `Table`. **Сложные data-списки** — ReUI data-grid (через `DataGridCard`), не shadcn Data Table.
|
|
||||||
|
|
||||||
## Уже установленные shared-обёртки
|
|
||||||
|
|
||||||
В `apps/web/src/components/`:
|
|
||||||
- `PageHeader`, `PageShell` — заголовки и обёртки страниц
|
|
||||||
- `QueryState` — обёртка loading/error/empty для TanStack Query
|
|
||||||
- `EmptyState` — пустые списки
|
|
||||||
- `ConfirmDialog` — подтверждения (не `window.confirm`)
|
|
||||||
- `LoadingButton` — кнопка с loading-состоянием
|
|
||||||
- `StatusBadge` — статусные бейджи
|
|
||||||
- `SectionCards` — сетка KPI-карточек
|
|
||||||
- `Skeletons` (`TableSkeleton`, `SectionCardsSkeleton`) — скелетоны
|
|
||||||
- `TruncatedText` — текст с тултипом
|
|
||||||
- `ModeToggle` — переключатель темы
|
|
||||||
|
|
||||||
Перед созданием новой обёртки — проверить существующие через Codegraph.
|
|
||||||
|
|
||||||
## Уже установленные ReUI-компоненты
|
|
||||||
|
|
||||||
В `apps/web/src/components/reui/`:
|
|
||||||
- `autocomplete`, `badge`, `data-grid/*`, `date-selector`, `filters`, `number-field`
|
|
||||||
|
|
||||||
Перед добавлением дубликата — проверить Codegraph и существующие обёртки.
|
|
||||||
|
|
||||||
## Зависимости (только `apps/web`, не `packages/ui`)
|
|
||||||
|
|
||||||
| npm-пакет | ReUI-компоненты |
|
|
||||||
|-----------|-----------------|
|
|
||||||
| `@tanstack/react-table` | data-grid |
|
|
||||||
| `@tanstack/react-virtual` | data-grid (virtual) |
|
|
||||||
| `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities` | data-grid dnd, sortable, kanban |
|
|
||||||
| `date-fns`, `react-day-picker` | date-selector |
|
|
||||||
|
|
||||||
После `shadcn add @reui/...` — проверить, что CLI добавил недостающие deps в `apps/web/package.json`.
|
|
||||||
|
|
||||||
## Semantic tokens (Styling)
|
|
||||||
|
|
||||||
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в `packages/ui/src/styles/globals.css`.
|
|
||||||
|
|
||||||
- Badge/Alert: `variant="success"` / `"info"` / `"warning"` — не `bg-emerald-*`
|
|
||||||
- Базовая тема: `pnpm dlx shadcn@latest apply b2fA --only theme`
|
|
||||||
- ReUI-токены: по [Styling guide](https://reui.io/docs/styling); не править `globals.css` вручную без сверки с docs
|
|
||||||
|
|
||||||
## Запрещено
|
|
||||||
|
|
||||||
- Писать UI по памяти, не проверив MCP
|
|
||||||
- Копипаст с ui.shadcn.com без examples/add из MCP
|
|
||||||
- Самописные примитивы, если есть item в registry
|
|
||||||
- Пропускать MCP «потому что компонент простой»
|
|
||||||
- Класть ReUI в `packages/ui` или импортировать как `@evobgp/ui`
|
|
||||||
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
|
|
||||||
- Raw Tailwind-цвета вместо ReUI semantic `variant`
|
|
||||||
- Использовать Tabler/Bootstrap/Material UI
|
|
||||||
|
|
||||||
## Чеклист перед завершением UI-задачи
|
|
||||||
|
|
||||||
- [ ] MCP search (с правильным registry) + examples
|
|
||||||
- [ ] `shadcn add <name>` (или `@reui/<name>`) из `apps/web`
|
|
||||||
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
|
||||||
- [ ] Зависимости в `apps/web/package.json`
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# shadcn-svelte в EvoBGP monorepo
|
||||||
|
|
||||||
|
Использовать при добавлении примитивов, блоков, правке темы в `apps/web` / `packages/ui`.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- Примитивы: `packages/ui/src/components/` → `@evobgp/ui/components/*`
|
||||||
|
- Паттерны приложения: `apps/web/src/lib/components/patterns/`
|
||||||
|
- CLI всегда из `apps/web/`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Проверить, есть ли компонент в `packages/ui/src/components/`
|
||||||
|
2. `cd apps/web && pnpm dlx shadcn-svelte@latest add <name> -y -o`
|
||||||
|
3. Если файлы в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`
|
||||||
|
4. Импорты shadcn в app: `@evobgp/ui/components/<name>/index.js`
|
||||||
|
5. `pnpm --filter @evobgp/web check && pnpm --filter @evobgp/web lint`
|
||||||
|
|
||||||
|
## Тема
|
||||||
|
|
||||||
|
- `packages/ui/src/styles/globals.css` с `@source`
|
||||||
|
- Импорт в `apps/web/src/routes/layout.css`: `@import '@evobgp/ui/styles/globals.css';`
|
||||||
|
|
||||||
|
## Не использовать
|
||||||
|
|
||||||
|
- React shadcn/ui примеры без адаптации под Svelte 5
|
||||||
|
- ReUI (`@reui/*`) — только React
|
||||||
|
- Legacy `$lib/components/ui/` re-exports
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- https://shadcn-svelte.com/docs
|
||||||
|
- https://shadcn-svelte.com/llms.txt
|
||||||
|
- Monorepo: `.cursor/rules/frontend-monorepo.mdc`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 48400,
|
"pid": 46304,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1783060842523
|
"startedAt": 1782824332519
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
---
|
|
||||||
name: EvoBGP → React+shadcn/ui+ReUI
|
|
||||||
overview: "Big-bang миграция web UI EvoBGP с SvelteKit+Svelte5 на React 19 + Vite + TanStack Router/Query + shadcn/ui (Base UI, base-nova) + ReUI registry, идентично эталону vps-tracker. Структура — pnpm workspaces monorepo (apps/web + packages/ui как @evobgp/ui). Скоуп: только фронтенд; Go-бэкенд, OpenAPI, nginx, compose и bake-конфиги сохраняются с минимальной правкой путей сборки. Старый web/ заменяется полностью; перенос доменных экранов идёт по одному роуту через MCP-shadcn с проверкой по docs."
|
|
||||||
todos:
|
|
||||||
- id: "1"
|
|
||||||
content: "Этап 0: Установить MCP в workspace EvoBGP (plugin-shadcn-shadcn с @reui, plugin-context7, cursor-ide-browser) — скопировать .cursor/mcp.json из vps-tracker"
|
|
||||||
status: pending
|
|
||||||
- id: "2"
|
|
||||||
content: "Этап 1: Архивировать web/ → web-legacy-svelte/, зафиксировать инвентарь 13 роутов и компонентов"
|
|
||||||
status: pending
|
|
||||||
- id: "3"
|
|
||||||
content: "Этап 2: Создать pnpm workspaces монорепо — pnpm-workspace.yaml, корневой package.json, tsconfig.base.json, .npmrc, .nvmrc"
|
|
||||||
status: pending
|
|
||||||
- id: "4"
|
|
||||||
content: "Этап 3: Создать packages/ui (@evobgp/ui) — package.json, components.json (base-nova + @reui), src/styles/globals.css (копия vps-tracker), lib/utils.ts, hooks/use-mobile.ts"
|
|
||||||
status: pending
|
|
||||||
- id: "5"
|
|
||||||
content: "Этап 4: Добавить shadcn-примитивы в packages/ui через MCP+CLI (33 компонента идентично vps-tracker)"
|
|
||||||
status: pending
|
|
||||||
- id: "6"
|
|
||||||
content: "Этап 5: Создать apps/web каркас — package.json (@evobgp/web), components.json, tsconfig, vite.config.ts (TanStackRouterPlugin+react+tailwindcss, alias @ и @evobgp/ui/*, proxy /v1)"
|
|
||||||
status: pending
|
|
||||||
- id: "7"
|
|
||||||
content: "Этап 6: React-инициализация — main.tsx (StrictMode→ThemeProvider→QC→Router+Toaster), lib/queryClient.ts, lib/router.ts (Register augmentation), routes/__root.tsx, theme-provider.tsx, index.html"
|
|
||||||
status: pending
|
|
||||||
- id: "8"
|
|
||||||
content: "Этап 7: Перенос app-shell (sidebar-07) и shared-обёрток (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, StatusBadge, DataGridCard, FormSheet, FormField, LoadingButton, skeletons) из vps-tracker с адаптацией брендинга"
|
|
||||||
status: pending
|
|
||||||
- id: "9"
|
|
||||||
content: "Этап 8: Перенос API-клиента и типов из legacy — api-client.ts (Bearer localStorage, Idempotency-Key, RFC 9457 Problem, waitForJob, apiPageAll), types/api.ts, queries/* по доменам"
|
|
||||||
status: pending
|
|
||||||
- id: "10"
|
|
||||||
content: "Этап 9: Добавить ReUI enterprise через MCP+CLI — @reui/data-grid, filters, autocomplete, date-selector, number-field, color-picker, badge"
|
|
||||||
status: pending
|
|
||||||
- id: "11"
|
|
||||||
content: "Этап 10.1: Роуты _auth layout + settings (token+theme) + access (session+api-keys) — простые экраны"
|
|
||||||
status: pending
|
|
||||||
- id: "12"
|
|
||||||
content: "Этап 10.2: Роуты index (dashboard KPI+recent) + modules/index (DataGridCard) + modules/$moduleId (детали с cards)"
|
|
||||||
status: pending
|
|
||||||
- id: "13"
|
|
||||||
content: "Этап 10.3: Роуты network (peers/speakers/BIRD tabs, live refetchInterval) + operations (jobs/revisions/diff, waitForJob) + schedule"
|
|
||||||
status: pending
|
|
||||||
- id: "14"
|
|
||||||
content: "Этап 10.4: Роуты directories (communities/DoH) + monitoring (bird/postgres/runtime) + tenant-settings + редиректы peers→network, revisions→operations"
|
|
||||||
status: pending
|
|
||||||
- id: "15"
|
|
||||||
content: "Этап 11: Обновить deploy/docker/evobgp-web/Dockerfile (pnpm+corepack, COPY apps/web + packages, dist вместо build); nginx.conf НЕ трогать; проверить bake"
|
|
||||||
status: pending
|
|
||||||
- id: "16"
|
|
||||||
content: "Этап 12: Обновить .gitea/workflows/ci.yaml web job (pnpm, tsc --noEmit, eslint, build) + path-filter apps/web/** + packages/ui/**"
|
|
||||||
status: pending
|
|
||||||
- id: "17"
|
|
||||||
content: "Этап 13: Cursor rules — удалить web-shadcn.mdc; скопировать shadcn-mcp/reui-mcp/frontend-* из vps-tracker; обновить engineering.mdc (DEP-04, TEST-04, DOC-SYNC) и context7-stack.mdc; создать .agents/skills/{shadcn,reui}"
|
|
||||||
status: pending
|
|
||||||
- id: "18"
|
|
||||||
content: "Этап 14: Финал — pnpm build без ошибок, MCP get_audit_checklist, cursor-ide-browser smoke 13 роутов, удалить web-legacy-svelte/, коммит feat(frontend)"
|
|
||||||
status: pending
|
|
||||||
isProject: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Plan: Миграция EvoBGP web UI на React + shadcn/ui + ReUI
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
**Цель:** Перевести `web/` EvoBGP с SvelteKit 2.50 + Svelte 5.54 + shadcn-svelte на стек идентичный `vps-tracker/apps/web`:
|
|
||||||
- **Стек:** React 19 + Vite 7 + TanStack Router/Query v5 + shadcn/ui (Base UI, `style: base-nova`) + ReUI registry `@reui` + Tailwind v4 monorepo + lucide-react + react-hook-form + Zod + recharts + sonner + next-themes
|
|
||||||
- **Структура:** pnpm workspaces monorepo — `apps/web` (SPA) + `packages/ui` (`@evobgp/ui` barrel)
|
|
||||||
- **Скоуп:** только frontend. Go-бэкенд (`internal/*`), OpenAPI (`docs/openapi.yaml`), compose/bake/nginx — не трогаются (минимальная правка только путей сборки в Dockerfile)
|
|
||||||
- **Стратегия:** big-bang. Старый `web/` архивируется в `web-legacy-svelte/` и удаляется в финале. Рабочий UI создаётся с нуля
|
|
||||||
- **Эталон:** `c:\Users\shats\Dev\vps-tracker\apps\web\` + `c:\Users\shats\Dev\vps-tracker\packages\ui\`
|
|
||||||
|
|
||||||
**Инвентарь существующего EvoBGP web** (из исследования):
|
|
||||||
- 13 роутов SvelteKit: `/`, `/modules`, `/modules/[id]`, `/network` (+редирект `/peers`), `/operations` (+редирект `/revisions`), `/monitoring`, `/schedule`, `/directories`, `/access`, `/tenant-settings`, `/settings`
|
|
||||||
- ~130 доменных `.svelte`-компонентов в `web/src/lib/components/{modules,network,operations,monitoring,tenant-settings,access,...}`
|
|
||||||
- API-клиент `web/src/lib/api/client.ts` + типы `web/src/lib/api/types.ts` (4301 строка OpenAPI, 76 эндпоинтов)
|
|
||||||
- Токен в localStorage (`evobgp_api_token`), Bearer, RFC 9457 errors, cursor-пагинация, Idempotency-Key, polling jobs
|
|
||||||
- Тема `neutral` в `web/src/routes/layout.css` с semantic tokens `--success/--warning/--info`
|
|
||||||
- nginx.conf — SPA fallback `try_files $uri $uri/ /index.html` (нейтрален к фреймворку, не меняется)
|
|
||||||
- Сейчас npm (НЕ pnpm), корневого `pnpm-workspace.yaml` нет
|
|
||||||
|
|
||||||
**Архитектура после миграции**:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph EvoBGP[EvoBGP repo root]
|
|
||||||
PWS[pnpm-workspace.yaml]
|
|
||||||
RP[package.json + pnpm-lock.yaml]
|
|
||||||
TSB[tsconfig.base.json]
|
|
||||||
NVM[.nvmrc Node 22]
|
|
||||||
MCP[.cursor/mcp.json]
|
|
||||||
subgraph Apps
|
|
||||||
AW[apps/web — React SPA]
|
|
||||||
end
|
|
||||||
subgraph Packages
|
|
||||||
UI[packages/ui — @evobgp/ui]
|
|
||||||
end
|
|
||||||
Deploy[deploy/ nginx+compose+bake]
|
|
||||||
GoAPI[internal/httpapi — Go API]
|
|
||||||
end
|
|
||||||
AW -->|imports| UI
|
|
||||||
AW -->|/v1 proxy dev| GoAPI
|
|
||||||
Deploy -->|Dockerfile build| AW
|
|
||||||
Deploy -->|nginx /v1 proxy| GoAPI
|
|
||||||
UI -->|globals.css @source| AW
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этапы (последовательность, safe-by-design)
|
|
||||||
|
|
||||||
### Этап 0 — MCP environment (предварительный)
|
|
||||||
|
|
||||||
Установить MCP-серверы в workspace EvoBGP. Источник: `c:\Users\shats\.cursor\projects\c-Users-shats-Dev-vps-tracker\mcps\` — там работают:
|
|
||||||
- `plugin-shadcn-shadcn` (serverName `shadcn`) — даёт инструменты `get_project_registries`, `search_items_in_registries` (с поддержкой `registries: ["@reui"]`), `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
|
|
||||||
- `plugin-context7-plugin-context7` — docs-lookup React/TanStack/Recharts
|
|
||||||
- `cursor-ide-browser` — визуальная проверка UI
|
|
||||||
|
|
||||||
Скопировать `.cursor/mcp.json` из vps-tracker → `c:\Users\shats\Dev\EvoBGP\.cursor\mcp.json`. Enable серверы в Cursor Settings. ReUI MCP и codegraph — **не существуют как отдельные MCP** (исследовано); ReUI доступен **через** `plugin-shadcn-shadcn` с `registries: ["@reui"]`.
|
|
||||||
|
|
||||||
### Этап 1 — Подготовка
|
|
||||||
|
|
||||||
1. **Зафиксировать инвентарь**: список 13 роутов + ~130 компонентов + API-клиент + типы. Не удалять пока ничего.
|
|
||||||
2. **Архивировать старый стек**: `web/` → `web-legacy-svelte/`. Оставить до финала как референс при переносе экранов.
|
|
||||||
|
|
||||||
### Этап 2 — Монорепо-каркас
|
|
||||||
|
|
||||||
Корневые файлы:
|
|
||||||
|
|
||||||
- `pnpm-workspace.yaml`: `packages: ['apps/*', 'packages/*']`
|
|
||||||
- Корневой `package.json`: `{"private": true, "scripts": {"dev": "pnpm --filter @evobgp/web dev", "build": "pnpm --filter @evobgp/web build", "lint": "pnpm -r lint"}}`. Существующий корневой `package.json` (semantic-release/commitlint) — слить в один или оставить как `package.release.json` (решить по ходу).
|
|
||||||
- `tsconfig.base.json`: `strict: true`, `target: ES2022`, `moduleResolution: bundler`, `jsx: react-jsx`, `paths: {"@/*": ["./apps/web/src/*"], "@evobgp/ui/components/*": ["./packages/ui/src/components/*"], "@evobgp/ui/hooks/*": ["./packages/ui/src/hooks/*"], "@evobgp/ui/lib/utils": ["./packages/ui/src/lib/utils.ts"]}`
|
|
||||||
- `.npmrc`: `engine-strict=true`
|
|
||||||
- `.nvmrc`: `22` (приводим к единой версии с Dockerfile)
|
|
||||||
- `.gitignore`: добавить `node_modules/`, `dist/`, `routeTree.gen.ts` (опц.)
|
|
||||||
|
|
||||||
### Этап 3 — `packages/ui` (@evobgp/ui)
|
|
||||||
|
|
||||||
Структура полностью повторяет `c:\Users\shats\Dev\vps-tracker\packages\ui\`:
|
|
||||||
|
|
||||||
- `packages/ui/package.json`:
|
|
||||||
- `name: "@evobgp/ui"`
|
|
||||||
- `exports`: `./components/*`, `./hooks/*`, `./lib/utils`, `./globals.css`
|
|
||||||
- `dependencies`: `@base-ui/react`, `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`, `tailwind-merge`
|
|
||||||
- `peerDependencies`: `react`, `react-dom` (19)
|
|
||||||
- `packages/ui/components.json` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "base-nova",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {"config": "", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"registries": {"@reui": "https://reui.io/r/{style}/{name}.json"},
|
|
||||||
"aliases": {"components": "@evobgp/ui/components", "utils": "@evobgp/ui/lib/utils", "hooks": "@evobgp/ui/hooks", "lib": "@evobgp/ui/lib", "ui": "@evobgp/ui/components"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `packages/ui/src/styles/globals.css`: **точная копия** `c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css` (Tailwind v4 + `tw-animate-css` + `@source "../"` + `@source "../../../apps/web/src"` + `:root`/`.dark` со всеми oklch-токенами + ReUI semantic tokens `--success/--info/--warning/--destructive-foreground/--invert/--focus` + chart-1..5 + sidebar-* + `@theme inline` + `@layer base`)
|
|
||||||
- `packages/ui/src/lib/utils.ts`: `cn()` через `clsx` + `tailwind-merge`
|
|
||||||
- `packages/ui/src/hooks/use-mobile.ts`
|
|
||||||
|
|
||||||
### Этап 4 — shadcn-примитивы через MCP
|
|
||||||
|
|
||||||
Перед каждым `add` — обязательно через MCP:
|
|
||||||
1. `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`
|
|
||||||
2. CLI `pnpm dlx shadcn@latest docs <component>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
|
||||||
|
|
||||||
Компоненты (идентично набору `vps-tracker/packages/ui/src/components/`):
|
|
||||||
```
|
|
||||||
button button-group card input textarea label select checkbox field
|
|
||||||
separator scroll-area table badge tabs dialog alert-dialog sheet popover
|
|
||||||
dropdown-menu tooltip breadcrumb sidebar skeleton sonner spinner alert
|
|
||||||
command kbd calendar slider chart input-group input-otp toggle
|
|
||||||
```
|
|
||||||
Команда: `cd apps/web && pnpm dlx shadcn@latest add button card input ...` (CLI пишет в `packages/ui/src/components/` через aliases).
|
|
||||||
|
|
||||||
Применение темы: `pnpm dlx shadcn@latest apply b2fA --only theme -y` (обновит `:root`/`.dark` в `globals.css`).
|
|
||||||
|
|
||||||
### Этап 5 — `apps/web` каркас
|
|
||||||
|
|
||||||
- `apps/web/package.json` (`@evobgp/web`): deps из `vps-tracker/apps/web/package.json` с заменой `@cfdm/*` → `@evobgp/*`:
|
|
||||||
- `react`, `react-dom` 19
|
|
||||||
- `@tanstack/react-router`, `@tanstack/react-router-devtools`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-table`, `@tanstack/react-virtual`
|
|
||||||
- `@hookform/resolvers`, `react-hook-form`, `zod` (v3 для совместимости с RHF-resolvers — как в vps-tracker)
|
|
||||||
- `class-variance-authority`, `cmdk`, `date-fns`, `lucide-react`, `next-themes`, `react-day-picker`, `recharts`, `sonner`
|
|
||||||
- `@dnd-kit/core`, `@dnd-kit/modifiers`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
|
|
||||||
- devDeps: `@tailwindcss/vite`, `@tanstack/router-plugin`, `@types/react`, `@types/react-dom`, `@vitejs/plugin-react`, `happy-dom`, `tailwindcss`, `tw-animate-css`, `typescript`, `vite`, `vitest`
|
|
||||||
- `apps/web/components.json` (алиасы web-side: `@/components`, `@/hooks`, `@/lib`, `utils: @evobgp/ui/lib/utils`, `ui: @evobgp/ui/components`; registries `@reui`; css `../../packages/ui/src/styles/globals.css`)
|
|
||||||
- `apps/web/vite.config.ts` (точная копия vps-tracker с заменой `@cfdm` → `@evobgp`): плагины `TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true })` → `react()` → `tailwindcss()`; alias `@`, `@evobgp/ui/*`; server port 5173, proxy `/v1` и `/metrics` → `http://127.0.0.1:8080`
|
|
||||||
- `apps/web/tsconfig.json` extends `../../tsconfig.base.json`
|
|
||||||
- `apps/web/index.html` (#root, anti-FOUC тема-скрипт `evobgp-theme`)
|
|
||||||
|
|
||||||
### Этап 6 — React-инициализация
|
|
||||||
|
|
||||||
Точная копия vps-tracker с заменой путей:
|
|
||||||
|
|
||||||
- `apps/web/src/main.tsx`: `StrictMode → ThemeProvider → QueryClientProvider → RouterProvider + <Toaster richColors position="top-right" />`, единственный `import '@evobgp/ui/globals.css'`
|
|
||||||
- `apps/web/src/lib/queryClient.ts`: `staleTime: 60_000, retry: 1, refetchOnWindowFocus: false`
|
|
||||||
- `apps/web/src/lib/router.ts`: `createRouter({ routeTree, context, defaultPreload: 'intent', scrollRestoration: true })` + `declare module '@tanstack/react-router'` Register
|
|
||||||
- `apps/web/src/components/theme-provider.tsx`: next-themes `attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange`
|
|
||||||
- `apps/web/src/routes/__root.tsx`: `createRootRouteWithContext<{ queryClient }>` + `<AppShell><Outlet /></AppShell>`
|
|
||||||
|
|
||||||
### Этап 7 — Перенос app-shell и shared-обёрток
|
|
||||||
|
|
||||||
Перенести из `vps-tracker/apps/web/src/components/` с адаптацией брендинга/навигации:
|
|
||||||
- `layout/app-shell.tsx` (block sidebar-07, `SidebarProvider → Sidebar collapsible="icon" → SidebarHeader/SidebarContent(navGroups)/SidebarFooter + SidebarInset(header sticky h-16 backdrop-blur + SidebarTrigger + Breadcrumb + actions + main)`). `render={<Link to={item.to} />}` — **Base UI render-prop, не Radix asChild**. Навигация подставляется под EvoBGP-экраны.
|
|
||||||
- `mode-toggle.tsx`, `page-shell.tsx`, `page-header.tsx`, `empty-state.tsx`, `query-state.tsx`, `confirm-dialog.tsx`, `status-badge.tsx`, `section-cards.tsx`, `form-sheet.tsx`, `form-field.tsx`, `loading-button.tsx`, `skeletons.tsx`
|
|
||||||
- `data-grid-card.tsx` (обёртка над `@reui/data-grid`) — полная копия с типизированным `DataGridCardProps<TData>`
|
|
||||||
|
|
||||||
### Этап 8 — API-клиент и типы (перенос из legacy)
|
|
||||||
|
|
||||||
- `apps/web/src/lib/api-client.ts`: перенос логики из `web-legacy-svelte/src/lib/api/client.ts`:
|
|
||||||
- `TOKEN_STORAGE_KEY = 'evobgp_api_token'`
|
|
||||||
- `mergeHeaders`: Accept JSON + Bearer из localStorage
|
|
||||||
- `apiFetch`, `apiJSON<T>`, `apiMutate<T>` с auto-Idempotency-Key
|
|
||||||
- `parseResponse<T>`: 204/205→undefined, ошибки → `ApiError` с RFC 9457 Problem
|
|
||||||
- `waitForJob(jobId, opts?)`: poll `GET /v1/jobs/{id}` каждые 400ms
|
|
||||||
- `apiPageAll<T>`: cursor-пагинация (`items`/`next_cursor`/`has_more`)
|
|
||||||
- `apps/web/src/types/api.ts`: перенос всех типов из `web-legacy-svelte/src/lib/api/types.ts` (`ModuleRow`, `BgpPeer`, `SpeakerRow`, `RevisionRow`, `JobRow`, `AuthSession`, `ApiKey`, `PostgresOverview`, etc.)
|
|
||||||
- `apps/web/src/queries/` — по доменам: `auth.ts`, `modules.ts`, `network.ts`, `operations.ts`, `monitoring.ts`, `directories.ts`, `access.ts`, `settings.ts` (queryOptions + key factories)
|
|
||||||
|
|
||||||
### Этап 9 — ReUI enterprise-компоненты
|
|
||||||
|
|
||||||
Через MCP `search_items_in_registries` с `registries: ["@reui"]` → `get_item_examples` → `get_add_command`:
|
|
||||||
```bash
|
|
||||||
cd apps/web
|
|
||||||
pnpm dlx shadcn@latest add @reui/data-grid
|
|
||||||
pnpm dlx shadcn@latest add @reui/filters
|
|
||||||
pnpm dlx shadcn@latest add @reui/autocomplete
|
|
||||||
pnpm dlx shadcn@latest add @reui/date-selector
|
|
||||||
pnpm dlx shadcn@latest add @reui/number-field
|
|
||||||
pnpm dlx shadcn@latest add @reui/color-picker
|
|
||||||
pnpm dlx shadcn@latest add @reui/badge
|
|
||||||
```
|
|
||||||
Документация для `@reui/*`: [reui.io/docs/components/base/](https://reui.io/docs/components/base) + [llms.txt](https://reui.io/llms.txt) — **не** ui.shadcn.com для ReUI.
|
|
||||||
|
|
||||||
Ложатся в `apps/web/src/components/reui/` (НЕ в packages/ui). Post-add: импорты shadcn-примитивов внутри ReUI → `@evobgp/ui/components/*`. Проверить что `@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker` попали в `apps/web/package.json`.
|
|
||||||
|
|
||||||
### Этап 10 — Реализация 13 роутов (поэтапно)
|
|
||||||
|
|
||||||
Каждый роут — отдельная подзадача. Порядок от простого к сложному (позволяет рано верифицировать стек):
|
|
||||||
|
|
||||||
| # | Файл (TanStack file-based) | Что делает | Источник (legacy) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `routes/_auth.tsx` + `_auth.tsx` layout | auth guard (токен в localStorage) | `routes/+layout.ts` |
|
|
||||||
| 2 | `routes/_auth/settings.tsx` | API-токен + тема (свет/тёмн/сист) | `routes/settings/+page.svelte` |
|
|
||||||
| 3 | `routes/_auth/access.tsx` | `GET /v1/auth/session` + список API-ключей | `routes/access/+page.svelte` |
|
|
||||||
| 4 | `routes/index.tsx` | dashboard: KPI + recent jobs/revisions + network status | `routes/+page.svelte` |
|
|
||||||
| 5 | `routes/_auth/modules/index.tsx` | список модулей (DataGridCard) | `routes/modules/+page.svelte` |
|
|
||||||
| 6 | `routes/_auth/modules/$moduleId.tsx` | детали модуля (cards: AS/Cdn/Domain/IpRange) | `routes/modules/[moduleId]/+page.svelte` |
|
|
||||||
| 7 | `routes/_auth/network.tsx` | peers + speakers + BIRD settings (tabs) | `routes/network/+page.svelte` |
|
|
||||||
| 8 | `routes/_auth/operations.tsx` | jobs + revisions + diff (tabs, waitForJob) | `routes/operations/+page.svelte` |
|
|
||||||
| 9 | `routes/_auth/schedule.tsx` | refresh jobs (tabs all/refresh/failed) | `routes/schedule/+page.svelte` |
|
|
||||||
| 10 | `routes/_auth/directories.tsx` | BGP communities + DoH profiles (tabs) | `routes/directories/+page.svelte` |
|
|
||||||
| 11 | `routes/_auth/monitoring.tsx` | bird status + version + postgres + runtime logs | `routes/monitoring/+page.svelte` |
|
|
||||||
| 12 | `routes/_auth/tenant-settings.tsx` | настройки BIRD/ревизий/runtime (tabs) | `routes/tenant-settings/+page.svelte` |
|
|
||||||
| 13 | редиректы | `/peers`→`/network`, `/revisions`→`/operations?tab=revisions` | `routes/peers`, `routes/revisions` |
|
|
||||||
|
|
||||||
Для каждого роута: MCP-shadcn search+examples → `pnpm dlx shadcn@latest docs <name>` сверка с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) → ReUI для data-grid/filters → композиция @evobgp/ui + DataGridCard; логика переносится из соответствующего `+page.svelte` (но на TanStack Query вместо инлайн fetch). Live-данные (network, monitoring) — `refetchInterval` вместо кастомного `setInterval`.
|
|
||||||
|
|
||||||
### Этап 11 — Инфраструктура деплоя
|
|
||||||
|
|
||||||
**Минимальные правки (compose/bake не трогать):**
|
|
||||||
|
|
||||||
`deploy/docker/evobgp-web/Dockerfile` (3 стадии → 3 стадии):
|
|
||||||
- `deps`: установить pnpm (corepack), `COPY pnpm-workspace.yaml package.json apps/web/package.json packages/ui/package.json ./` + `COPY apps/web/ apps/web/` + `COPY packages/ packages/` → `pnpm install --frozen-lockfile`
|
|
||||||
- `build`: `COPY . .` → `pnpm --filter @evobgp/web build`
|
|
||||||
- `web`: `COPY --from=web-artifacts /app/apps/web/dist /usr/share/nginx/html` (вместо `/web/build`)
|
|
||||||
|
|
||||||
`deploy/docker/evobgp-web/nginx.conf` — **не меняется** (SPA fallback совместим).
|
|
||||||
|
|
||||||
`deploy/docker/docker-bake.hcl` — проверить что target `web-build` корректно прокидывает контекст.
|
|
||||||
|
|
||||||
### Этап 12 — CI/CD
|
|
||||||
|
|
||||||
`.gitea/workflows/ci.yaml`, job `web` (строки 164-181):
|
|
||||||
- `actions/setup-node@v4` → `node-version: "22"`, `cache: pnpm`, `cache-dependency-path: pnpm-lock.yaml`
|
|
||||||
- Добавить `pnpm install` (через corepack)
|
|
||||||
- Заменить `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`
|
|
||||||
- Заменить `npm run lint` → `pnpm --filter @evobgp/web lint` (eslint)
|
|
||||||
- Добавить `pnpm --filter @evobgp/web build` (раньше проверялось только в Docker)
|
|
||||||
|
|
||||||
Job `changes` — path-filter `web/*` → `apps/web/**` + `packages/ui/**` (расширить globs).
|
|
||||||
|
|
||||||
### Этап 13 — Cursor rules + skills
|
|
||||||
|
|
||||||
**Удалить** (Svelte-специфика):
|
|
||||||
- `.cursor/rules/web-shadcn.mdc` (WEB-01..WEB-19 — всё про shadcn-svelte)
|
|
||||||
|
|
||||||
**Скопировать из vps-tracker `.cursor/rules/`** (с заменой `@cfdm` → `@evobgp`, `Vps*` → `EvoBgp*`):
|
|
||||||
- `shadcn-mcp.mdc`, `reui-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-shadcn.mdc`, `frontend-monorepo.mdc`, `frontend-ui-patterns.mdc`, `vite-tanstack-frontend.mdc`
|
|
||||||
|
|
||||||
**Обновить** `engineering.mdc`:
|
|
||||||
- `DEP-04`: «shadcn-svelte/bits-ui» → «shadcn/ui React (Base UI) + ReUI registry»
|
|
||||||
- `TEST-04`: `npm run check` → `pnpm --filter @evobgp/web exec tsc --noEmit`; `npm run lint` → eslint
|
|
||||||
- `DOC-SYNC-06/07`: `shadcn-svelte.com` → `ui.shadcn.com/docs` + `reui.io/docs`
|
|
||||||
|
|
||||||
**Обновить** `context7-stack.mdc` Web UI таблица: убрать svelte/sveltekit/bits-ui/formsnap/`@lucide/svelte`; добавить React 19, TanStack Router/Query/Table/Virtual, ReUI llms.txt, `lucide-react`.
|
|
||||||
|
|
||||||
**Создать** `c:\Users\shats\Dev\EvoBGP\.agents\skills\` (сейчас не существует):
|
|
||||||
- `shadcn/SKILL.md` — скопировать из cloudflare-domain-manager
|
|
||||||
- `reui/SKILL.md` — скопировать из cloudflare-domain-manager, заменить обёртки под EvoBGP (`DataGridCard`, `NetworkFiltersToolbar`)
|
|
||||||
|
|
||||||
### Этап 14 — Финальная проверка и очистка
|
|
||||||
|
|
||||||
1. `pnpm install && pnpm --filter @evobgp/web build` без ошибок
|
|
||||||
2. MCP `get_audit_checklist` — пройти по чек-листу
|
|
||||||
3. `cursor-ide-browser` smoke-тест всех 13 роутов (login через dev-токен, навигация, CRUD, dark/light toggle)
|
|
||||||
4. Удалить `web-legacy-svelte/`
|
|
||||||
5. Проверить что compose поднимается (`docker compose --profile reference up`)
|
|
||||||
6. Коммит на main (gitflow по `.cursor/rules/conventional-commits.mdc`): `feat(frontend): миграция web UI на React + shadcn/ui + ReUI`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Цитаты ключевых файлов эталона
|
|
||||||
|
|
||||||
- Конфиги: [apps/web/components.json](c:\Users\shats\Dev\vps-tracker\apps\web\components.json), [packages/ui/components.json](c:\Users\shats\Dev\vps-tracker\packages\ui\components.json)
|
|
||||||
- Стили: [packages/ui/src/styles/globals.css](c:\Users\shats\Dev\vps-tracker\packages\ui\src\styles\globals.css)
|
|
||||||
- Инициализация: [apps/web/src/main.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\main.tsx), [lib/queryClient.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\queryClient.ts), [lib/router.ts](c:\Users\shats\Dev\vps-tracker\apps\web\src\lib\router.ts)
|
|
||||||
- Layout: [components/layout/app-shell.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\layout\app-shell.tsx)
|
|
||||||
- Vite: [apps/web/vite.config.ts](c:\Users\shats\Dev\vps-tracker\apps\web\vite.config.ts)
|
|
||||||
- ReUI обёртка: [components/data-grid-card.tsx](c:\Users\shats\Dev\vps-tracker\apps\web\src\components\data-grid-card.tsx)
|
|
||||||
- API-клиент legacy: [web/src/lib/api/client.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\client.ts), [web/src/lib/api/types.ts](c:\Users\shats\Dev\EvoBGP\web\src\lib\api\types.ts)
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
- [shadcn/ui Installation](https://ui.shadcn.com/docs/installation)
|
|
||||||
- [shadcn/ui Components](https://ui.shadcn.com/docs/components)
|
|
||||||
- [shadcn/ui Monorepo](https://ui.shadcn.com/docs/monorepo)
|
|
||||||
- [shadcn/ui MCP Server](https://ui.shadcn.com/docs/mcp)
|
|
||||||
- [ReUI Get Started](https://reui.io/docs/get-started)
|
|
||||||
- [ReUI Styling](https://reui.io/docs/styling)
|
|
||||||
- [ReUI MCP](https://reui.io/docs/mcp)
|
|
||||||
- [ReUI llms.txt](https://reui.io/llms.txt)
|
|
||||||
|
|
||||||
## Риски и митигация
|
|
||||||
|
|
||||||
| Риск | Митигация |
|
|
||||||
|---|---|
|
|
||||||
| Big-bang = длинное окно неработающего UI в dev | Этапы 3-9 делаются параллельно с рабочим `web-legacy-svelte/`; переключение атомарно в финале |
|
|
||||||
| ReUI на React 19 / Base UI может иметь breaking changes | MCP `get_item_examples` + dry-run `--dry-run` перед add |
|
|
||||||
| OpenAPI drift (типы в legacy vs контракт) | Типы переносятся как есть (frontend-only scope); Zod-схемы по желанию позже |
|
|
||||||
| npm → pnpm меняет lock-файлы и CI | Пункт 12 явно покрывает CI; corepack в Dockerfile |
|
|
||||||
| 13 роутов = большой объём работы | Этап 10 разбит по сложности; можно остановиться после базовых и продолжить инкрементально |
|
|
||||||
@@ -32,28 +32,23 @@ alwaysApply: true
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web UI (`apps/web/` + `packages/ui/`)
|
## Web UI (`web/`)
|
||||||
|
|
||||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||||
|------------|-------------|------------------|-------|
|
|------------|-------------|------------------|-------|
|
||||||
| React | `/facebook/react` | ^19.2 | hooks, components, JSX |
|
| Svelte | `/websites/svelte_dev` | ^5.54 | runes, компоненты, реактивность |
|
||||||
| TanStack Router | `/tanstack/router` | ^1.130 | file-based routes, `createFileRoute`, `useSearch`, `Link` |
|
| SvelteKit | `/sveltejs/kit` | ^2.50 | routing, `load`, adapters, SSR |
|
||||||
| TanStack Query | `/tanstack/query` | ^5.90 | `useQuery`, `useMutation`, `queryOptions`, invalidation |
|
|
||||||
| TanStack Table | `/websites/tanstack_table` | ^8.21 | data-grid колонки, сортировка (ReUI) |
|
|
||||||
| TanStack Virtual | `/tanstack/virtual` | ^3.14 | виртуализация списков (ReUI data-grid) |
|
|
||||||
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
| Vite | `/vitejs/vite/v7.3.1` | ^7.3.1 | dev server, build, plugins |
|
||||||
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
| TypeScript | `/microsoft/typescript/v5.9.3` | ^5.9.3 | типы, strict, tsconfig |
|
||||||
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
| Tailwind CSS | `/tailwindlabs/tailwindcss.com` | ^4.1 | v4, `@tailwindcss/vite`, утилиты |
|
||||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs | base-nova | примитивы `@evobgp/ui/components/*` |
|
| shadcn-svelte | `/websites/shadcn-svelte` | CLI | примитивы `ui/core`, theming |
|
||||||
| ReUI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` | registry | enterprise: data-grid, filters, autocomplete |
|
| Bits UI | `/llmstxt/bits-ui_llms_txt` | ^2.17 | headless-примитивы под shadcn |
|
||||||
| react-hook-form | `/react-hook-form` | ^7.60 | формы, controller |
|
| sveltekit-superforms | `/ciscoheat/sveltekit-superforms` | ^2.30 | формы, server actions |
|
||||||
| Zod | `/websites/zod_dev_v4` | ^3.25 / ^4 (apps/web) | схемы валидации |
|
| Formsnap | `/svecosystem/formsnap` | ^2.0 | доступные поля форм |
|
||||||
| recharts | `/recharts/recharts` | 3.8.0 | графики через shadcn `Chart` |
|
| Zod | `/websites/zod_dev_v4` | ^4.4 | схемы валидации |
|
||||||
| next-themes | `/pacocoursey/next-themes` | ^0.4 | dark/light theme provider |
|
| TanStack Table | `/websites/tanstack_table` | table-core ^8.21 | `AppDataTable`, колонки, сортировка |
|
||||||
| sonner | `/emilkowalski/sonner` | ^1.7 | toast notifications |
|
|
||||||
| lucide-react | `/lucide-icons/lucide` | ^0.468 | иконки |
|
|
||||||
|
|
||||||
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP + shadcn/ui React docs — первичный источник для компонентов).
|
UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (shadcn-svelte docs — первичный источник для компонентов).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,14 +77,12 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
|
|||||||
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
|
||||||
2. **Context7** — синтаксис и API библиотек из таблицы.
|
2. **Context7** — синтаксис и API библиотек из таблицы.
|
||||||
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
|
||||||
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
|
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
|
||||||
|
|
||||||
## Примеры запросов
|
## Примеры запросов
|
||||||
|
|
||||||
```
|
```
|
||||||
/docs /facebook/react hooks useState useEffect
|
/docs /websites/svelte_dev runes $state $derived
|
||||||
/docs /tanstack/router createFileRoute useSearch Link
|
|
||||||
/docs /tanstack/query useQuery useMutation queryOptions
|
|
||||||
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
|
/docs /golang/go/go1_24_6 net/http ServeMux pattern matching
|
||||||
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
/docs /websites/pkg_go_dev_github_com_jackc_pgx_v5 pool acquire rows
|
||||||
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
/docs /llmstxt/bird_xmsl_dev_llms_txt filter bgp import
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ alwaysApply: true
|
|||||||
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
**DEP-03** | MUST | Миграции схемы — пары `.up.sql`/`.down.sql` для **postgres** и **sqlite**, синхронная нумерация.
|
||||||
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
*Проверка:* `migrations/postgres/`, `migrations/sqlite/`.
|
||||||
|
|
||||||
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn/ui (React) + ReUI (см. `web-shadcn.mdc`).
|
**DEP-04** | MUST | Web UI-библиотеки — только экосистема shadcn-svelte/bits-ui (см. `web-shadcn.mdc`).
|
||||||
*Проверка:* `apps/web/package.json`, `packages/ui/package.json` review.
|
*Проверка:* `web/package.json` review.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ alwaysApply: true
|
|||||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||||
*Проверка:* CI job `bird2`.
|
*Проверка:* 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 | Изменения `web/` — локально **`npm run check` и `npm run lint`** (обе команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`. Агент: при fail lint — `npx prettier --write` затем повтор. Только `check` не заменяет `lint`.
|
||||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
*Проверка:* 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 — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||||
@@ -207,13 +207,13 @@ alwaysApply: true
|
|||||||
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
|
||||||
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
|
||||||
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
| shadcn-svelte | https://shadcn-svelte.com/docs |
|
||||||
| BIRD 2 | https://bird.nic.cz/?get_doc |
|
| BIRD 2 | https://bird.network.cz/?get_doc |
|
||||||
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
|
||||||
|
|
||||||
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
|
||||||
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
**DOC-SYNC-02** | NEVER | Устаревшие примеры (Svelte 4 `export let`, deprecated pgx).
|
||||||
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
**DOC-SYNC-03** | MUST | Конфликт docs: **OpenAPI (HTTP)** → **код** → обзорные `docs/`; `.cursor/plans/` не контракт.
|
||||||
**DOC-SYNC-04** | MUST | Сомнения по React/shadcn/ReUI — MCP `plugin-shadcn-shadcn` + `pnpm --filter @evobgp/web run typecheck`.
|
**DOC-SYNC-04** | MUST | Сомнения по Svelte — Svelte MCP / `npm run check`.
|
||||||
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
**DOC-SYNC-05** | MUST | BIRD — официальная документация BIRD2 + `networking-bird.mdc` + `go test ./internal/birdfmt/...`.
|
||||||
|
|
||||||
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
Приоритет при сомнениях — **официальные источники**, не блоги и не «память модели».
|
||||||
@@ -230,7 +230,7 @@ alwaysApply: true
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./... -race -count=1
|
go test ./... -race -count=1
|
||||||
npx @redocly/cli lint docs/openapi.yaml
|
npx @redocly/cli lint docs/openapi.yaml
|
||||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
# web: cd web; npm run check; npm run lint (или scripts/lint-web.ps1)
|
||||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
description: shadcn-svelte Monorepo — apps/web + packages/ui + packages/shared, CLI workflow
|
||||||
|
globs: apps/web/**/*,packages/ui/**/*,packages/shared/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Monorepo (shadcn-svelte)
|
||||||
|
|
||||||
|
Структура по образцу vps-tracker, стек — **SvelteKit 2 + Svelte 5 + shadcn-svelte** (не React/ReUI).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/ # SvelteKit (routes, queries, domain components)
|
||||||
|
packages/ui/ # @evobgp/ui — shadcn-svelte primitives (только CLI output)
|
||||||
|
packages/shared/ # @evobgp/shared — Zod contracts, API types
|
||||||
|
```
|
||||||
|
|
||||||
|
Go API в корне репозитория (`internal/`, `cmd/`) без изменений.
|
||||||
|
|
||||||
|
## Два components.json
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `apps/web/components.json` | App aliases; `ui` → `@evobgp/ui/components` |
|
||||||
|
| `packages/ui/components.json` | UI package aliases |
|
||||||
|
|
||||||
|
**Синхронизировать:** `style`, `iconLibrary`, `baseColor` в обоих файлах.
|
||||||
|
|
||||||
|
## CLI — только из apps/web
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn-svelte@latest add button -y -o
|
||||||
|
pnpm dlx shadcn-svelte@latest add sidebar -y -o
|
||||||
|
pnpm dlx shadcn-svelte@latest add breadcrumb -y -o
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед обновлением существующих компонентов: `pnpm dlx shadcn-svelte@latest add button --dry-run`
|
||||||
|
|
||||||
|
## Куда CLI кладёт файлы
|
||||||
|
|
||||||
|
| Команда | Куда |
|
||||||
|
|---------|------|
|
||||||
|
| `add button` | `packages/ui/src/components/button/` |
|
||||||
|
| `add sidebar` | `packages/ui/src/components/sidebar/` |
|
||||||
|
|
||||||
|
Если CLI кладёт в `apps/web/@evobgp/ui/` — перенести в `packages/ui/src/components/`.
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils';
|
||||||
|
import '@evobgp/ui/styles/globals.css'; // только в routes/layout.css
|
||||||
|
import type { ModuleRow } from '@evobgp/shared/types/api.js';
|
||||||
|
import { moduleCreateSchema } from '@evobgp/shared/contracts/modules.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Запрещено | Разрешено |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `$lib/ui/core/*` | `@evobgp/ui/components/*` |
|
||||||
|
| `apps/web/src/lib/components/ui/` (legacy re-export) | `packages/ui/src/components/` |
|
||||||
|
| Ручное редактирование темы вне CLI | shadcn-svelte theming docs |
|
||||||
|
|
||||||
|
## globals.css
|
||||||
|
|
||||||
|
`packages/ui/src/styles/globals.css` с `@source` на `packages/ui` и `apps/web/src`.
|
||||||
|
|
||||||
|
## Проверка после правок
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm --filter @evobgp/web check
|
||||||
|
pnpm --filter @evobgp/web lint
|
||||||
|
```
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: UI-паттерны apps/web — shared components, spacing, матрица (Svelte, без ReUI)
|
||||||
|
globs: apps/web/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend UI Patterns (Svelte)
|
||||||
|
|
||||||
|
Эталон UX: vps-tracker (`PageShell`, `QueryState`, `SectionCards`, `DataTableCard`). ReUI **не используется** — Data Table = shadcn-svelte + `AppDataTable` / `DataTableCard`.
|
||||||
|
|
||||||
|
## Иерархия
|
||||||
|
|
||||||
|
```
|
||||||
|
@evobgp/ui/components/* ← shadcn-svelte CLI (packages/ui)
|
||||||
|
apps/web/src/lib/components/ ← shared + domain + layout
|
||||||
|
page-shell.svelte
|
||||||
|
query-state.svelte
|
||||||
|
section-cards.svelte
|
||||||
|
data-table-card.svelte
|
||||||
|
status-badge.svelte
|
||||||
|
form-sheet.svelte
|
||||||
|
crud-list-page.svelte
|
||||||
|
list-filters-bar.svelte
|
||||||
|
patterns/ ← AppDataTable, FormField, ConfirmDialog, EmptyState
|
||||||
|
layout/app-shell.svelte
|
||||||
|
domain-*/ ← modules, network, operations…
|
||||||
|
```
|
||||||
|
|
||||||
|
## Матрица
|
||||||
|
|
||||||
|
| Элемент | Компонент | Primitive |
|
||||||
|
|---------|-----------|-----------|
|
||||||
|
| Page wrapper | `PageShell` | — |
|
||||||
|
| Page title | `PageHeader` (`ui/app/page-header`) | — |
|
||||||
|
| Stat metrics | `SectionCards` | `Card` |
|
||||||
|
| Data list | `DataTableCard` + `AppDataTable` | `Table` |
|
||||||
|
| Filters | `ListFiltersBar` | `Select`, `Badge` |
|
||||||
|
| Empty | `EmptyState` | — |
|
||||||
|
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||||
|
| Status | `StatusBadge` | `Badge` |
|
||||||
|
| Create/Edit form | `FormSheet` / Dialog | `Sheet`, `Field` |
|
||||||
|
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
|
||||||
|
| Nav | `AppShell` | `Sidebar` |
|
||||||
|
| Breadcrumbs | `AppShell` header | `Breadcrumb` |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `flex` + `gap-*`, не `space-y-*`
|
||||||
|
- Page: `gap-4 md:gap-6`, padding `p-4 md:p-6`
|
||||||
|
- Loading страницы → `Skeleton`, не page-level `Spinner`
|
||||||
|
- Max 1 primary CTA на экран
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- shadcn-svelte: https://shadcn-svelte.com/docs
|
||||||
|
- llms.txt: https://shadcn-svelte.com/llms.txt
|
||||||
|
- Data layer: `@tanstack/svelte-query` в `apps/web/src/lib/queries/`
|
||||||
|
|
||||||
|
## Overlay
|
||||||
|
|
||||||
|
| Сценарий | Компонент |
|
||||||
|
|----------|-----------|
|
||||||
|
| Create/edit | `Sheet` / `FormSheet` |
|
||||||
|
| Destructive | `ConfirmDialog` |
|
||||||
|
| Preview | `Dialog` |
|
||||||
@@ -23,7 +23,7 @@ alwaysApply: false
|
|||||||
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
|
||||||
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
|
||||||
|
|
||||||
**BIRD2 docs:** https://bird.nic.cz/?get_doc
|
**BIRD2 docs:** https://bird.network.cz/?get_doc
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Documentation Sync
|
## Documentation Sync
|
||||||
|
|
||||||
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
|
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
|
||||||
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
|
||||||
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
|
||||||
|
|
||||||
|
|||||||
+31
-107
@@ -1,131 +1,55 @@
|
|||||||
---
|
---
|
||||||
description: EvoBGP WebUI — React 19, shadcn/ui (base-nova), ReUI, TanStack Router/Query
|
description: EvoBGP WebUI — shadcn-svelte monorepo, Svelte 5, @evobgp/ui
|
||||||
globs:
|
globs:
|
||||||
- apps/web/**
|
- apps/web/**
|
||||||
- packages/ui/**
|
- packages/ui/**
|
||||||
|
- packages/shared/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Web UI — React + shadcn/ui + ReUI
|
# Web UI — shadcn-svelte (monorepo)
|
||||||
|
|
||||||
**Источники правды:**
|
**Источник правды:** https://shadcn-svelte.com/docs
|
||||||
- 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`.
|
Monorepo: [`frontend-monorepo.mdc`](frontend-monorepo.mdc), паттерны: [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc). Skill: [`.agents/skills/shadcn-svelte/SKILL.md`](.agents/skills/shadcn-svelte/SKILL.md).
|
||||||
|
|
||||||
## Слои UI
|
## Слои
|
||||||
|
|
||||||
| Слой | Путь | Назначение |
|
| Слой | Путь | Назначение |
|
||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| Примитивы | `packages/ui/src/components/` | shadcn-svelte (только CLI) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| Паттерны | `apps/web/src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
| Shared UI | `apps/web/src/lib/components/` | PageShell, QueryState, SectionCards |
|
||||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
| App chrome | `apps/web/src/lib/ui/app/` | PageHeader, toast, nav |
|
||||||
|
| Контракты | `packages/shared/` | types + Zod |
|
||||||
|
|
||||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
Тема: `packages/ui/src/styles/globals.css` → импорт в `apps/web/src/routes/layout.css`.
|
||||||
|
|
||||||
## Правила
|
CLI из `apps/web/`:
|
||||||
|
|
||||||
**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-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).
|
|
||||||
|
|
||||||
**WEB-03** | NEVER | Альтернативные UI-kit'ы (Material, Vuetify, Tabler, Bootstrap утилиты).
|
|
||||||
*Проверка:* `apps/web/package.json` review.
|
|
||||||
|
|
||||||
**WEB-04** | MUST | Композиция по docs: использовать под-компоненты (`CardHeader`, `TableRow`, `TabsList`, …).
|
|
||||||
*Проверка:* сверка с shadcn/ReUI docs.
|
|
||||||
|
|
||||||
**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-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
|
||||||
|
|
||||||
**WEB-08** | MUST | Иконки — `lucide-react` (`components.json` → `iconLibrary: lucide`).
|
|
||||||
*Проверка:* imports; нет `@tabler/icons-react`, `@lucide/svelte`.
|
|
||||||
|
|
||||||
**WEB-09** | MUST | Цвета — CSS-переменные `globals.css` и ReUI semantic токены (`variant="success"/"info"/"warning"`); не hex/rgb на страницах.
|
|
||||||
*Проверка:* grep `#[0-9a-f]{3,6}` в `apps/web/src/routes/`.
|
|
||||||
|
|
||||||
**WEB-10** | SHOULD | Кастомизация — правка `packages/ui`/`reui` (Open Code), не `!important` поверх API.
|
|
||||||
|
|
||||||
**WEB-11** | MUST | `apps/web/src/routes/**` — композиция `@evobgp/ui/components/*` + `@/components/*` + `@/components/reui/*`; не копировать целые примитивы в route.
|
|
||||||
*Проверка:* review.
|
|
||||||
|
|
||||||
**WEB-12** | NEVER | Примеры Svelte/SvelteKit, Tabler, Bootstrap — без адаптации под текущий React-стек.
|
|
||||||
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
|
|
||||||
|
|
||||||
**WEB-13** | MUST | Реактивность — React 19 (`useState`, `useEffect`, TanStack Query/Router хуки); не Svelte runes, не `export let`.
|
|
||||||
*Проверка:* `pnpm --filter @evobgp/web run typecheck`.
|
|
||||||
|
|
||||||
**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-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
|
||||||
|
|
||||||
**WEB-17** | MUST | Пустые списки — `EmptyState` или через `QueryState` с `emptyTitle`.
|
|
||||||
*Проверка:* review.
|
|
||||||
|
|
||||||
**WEB-18** | SHOULD | Повторяемая комбинация core (≥2 раза) — вынести в `apps/web/src/components/`.
|
|
||||||
|
|
||||||
**WEB-19** | MUST | **После любого изменения `apps/web/**` или `packages/ui/**`** — перед завершением задачи агент **обязан**:
|
|
||||||
```powershell
|
```powershell
|
||||||
pnpm --filter @evobgp/web run typecheck
|
pnpm dlx shadcn-svelte@latest add <component> -y -o
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
```
|
```
|
||||||
Все три команды должны exit 0. Не сдавать PR/ответ, пока все три не пройдут.
|
|
||||||
*Rationale:* CI job `web` = typecheck + lint + build.
|
|
||||||
*Проверка:* CI job `web`.
|
|
||||||
|
|
||||||
**WEB-20** | MUST | Роутинг — TanStack Router (file-based `apps/web/src/routes/`); типобезопасные `createFileRoute`, `useSearch`, `Link`. Не `react-router-dom`.
|
## Правила (кратко)
|
||||||
*Проверка:* `tsr generate` в `build`/`typecheck` скриптах.
|
|
||||||
|
|
||||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
- **WEB-01** Новый UI — сначала shadcn-svelte docs; не HTML+CSS с нуля
|
||||||
*Проверка:* review `queries/*.ts`.
|
- **WEB-02** Примитивы только через CLI → `packages/ui`
|
||||||
|
- **WEB-05** Формы — Formsnap + superforms; `patterns/form/`
|
||||||
|
- **WEB-06** Таблицы — `AppDataTable` / `DataTableCard`
|
||||||
|
- **WEB-07** Toast — `notify` из `ui/app/toast.ts`
|
||||||
|
- **WEB-08** Иконки — `@lucide/svelte`
|
||||||
|
- **WEB-09** Цвета — CSS variables; не hex в routes
|
||||||
|
- **WEB-13** Svelte 5 runes
|
||||||
|
- **WEB-16** Удаление — `ConfirmDialog`
|
||||||
|
- **WEB-17** Пустые списки — `EmptyState`
|
||||||
|
- **WEB-19** После правок `apps/web/**`:
|
||||||
|
|
||||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
```powershell
|
||||||
|
pnpm --filter @evobgp/web check
|
||||||
---
|
pnpm --filter @evobgp/web lint
|
||||||
|
```
|
||||||
## Documentation Sync (Web)
|
|
||||||
|
|
||||||
**DOC-SYNC-06** | MUST | UI — первично MCP + shadcn/ui docs (React) + ReUI docs (Base UI); при конфликте с блогами/Stack Overflow побеждает официальная страница.
|
|
||||||
**DOC-SYNC-07** | MUST | Перед `add` — сверить Installation/Theming с `apps/web/components.json`, `packages/ui/components.json` и `packages/ui/src/styles/globals.css`.
|
|
||||||
|
|
||||||
Tailwind v4 + base-nova: https://ui.shadcn.com/docs/migration/tailwind-v4
|
|
||||||
ReUI semantic tokens: https://reui.io/docs/styling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
**Обязательный финальный шаг агента при правках `apps/web/**` или `packages/ui/**`** (см. **WEB-19**):
|
PR checklist: `check` + `lint` exit 0; примитивы в `@evobgp/ui`, не дубли в routes.
|
||||||
|
|
||||||
```powershell
|
|
||||||
pnpm --filter @evobgp/web run typecheck
|
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**PR checklist `apps/web/**` / `packages/ui/**`:**
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run typecheck` — exit 0
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run lint` — exit 0
|
|
||||||
- [ ] `pnpm --filter @evobgp/web run build` — exit 0
|
|
||||||
- [ ] shadcn-примитивы в `packages/ui/src/components/`, ReUI в `apps/web/src/components/reui/`
|
|
||||||
- [ ] Новые примитивы через shadcn CLI (`@shadcn` или `@reui`)
|
|
||||||
- [ ] Импорты: `@evobgp/ui/components/*` для shadcn, `@/components/reui/*` для ReUI
|
|
||||||
- [ ] Ссылка на docs компонента (если новый паттерн)
|
|
||||||
|
|
||||||
**CI:** job `web` — `typecheck` + `lint` + `build`.
|
|
||||||
|
|||||||
+1
-10
@@ -1,19 +1,10 @@
|
|||||||
{
|
{
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shadcn": {
|
"svelte": {
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"context7-plugin": {
|
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"claude-plugins-official/gopls-lsp": {
|
"claude-plugins-official/gopls-lsp": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
|
||||||
"claude-plugins-official/typescript-lsp": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"svelte": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: Context7 lookup для стека EvoBGP — использоват
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Определи область: `internal/` (Go), `apps/web/` + `packages/ui/` (React + shadcn/ui + ReUI), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
1. Определи область: `internal/` (Go), `web/` (Svelte), `docs/openapi.yaml`, `birdfmt`/`pipeline` (BIRD), `deploy/compose` (Docker).
|
||||||
2. Найди строку в таблице `context7-stack.mdc`.
|
2. Найди строку в таблице `context7-stack.mdc`.
|
||||||
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
3. Вызови `query-docs` с `libraryId` из таблицы и полным вопросом пользователя.
|
||||||
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
4. `resolve-library-id` — только если библиотеки нет в таблице или нужна другая major-версия.
|
||||||
@@ -18,17 +18,14 @@ description: Context7 lookup для стека EvoBGP — использоват
|
|||||||
|
|
||||||
| Задача | libraryId |
|
| Задача | libraryId |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| React 19 hooks | `/facebook/react` |
|
| Svelte 5 runes | `/websites/svelte_dev` |
|
||||||
| TanStack Router | `/tanstack/router` |
|
| SvelteKit load/forms | `/sveltejs/kit` |
|
||||||
| TanStack Query | `/tanstack/query` |
|
| shadcn-svelte компонент | `/websites/shadcn-svelte` |
|
||||||
| shadcn/ui (React) | MCP `plugin-shadcn-shadcn` + https://ui.shadcn.com/docs |
|
|
||||||
| ReUI Base UI | https://reui.io/llms.txt + MCP с `registries: ["@reui"]` |
|
|
||||||
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
| pgx pool/query | `/websites/pkg_go_dev_github_com_jackc_pgx_v5` |
|
||||||
| Go net/http | `/golang/go/go1_24_6` |
|
| Go net/http | `/golang/go/go1_24_6` |
|
||||||
| OpenAPI lint | `/redocly/redocly-cli` |
|
| OpenAPI lint | `/redocly/redocly-cli` |
|
||||||
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
| BIRD config | `/llmstxt/bird_xmsl_dev_llms_txt` |
|
||||||
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
| Tailwind v4 | `/tailwindlabs/tailwindcss.com` |
|
||||||
| Zod schema | `/websites/zod_dev_v4` |
|
| Zod 4 schema | `/websites/zod_dev_v4` |
|
||||||
| recharts | `/recharts/recharts` |
|
|
||||||
|
|
||||||
Полный список и версии — в `context7-stack.mdc`.
|
Полный список и версии — в `context7-stack.mdc`.
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ jobs:
|
|||||||
openapi=true
|
openapi=true
|
||||||
go=true
|
go=true
|
||||||
;;
|
;;
|
||||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
apps/web/README.md|apps/web/components.json)
|
||||||
;;
|
;;
|
||||||
apps/web/*|packages/ui/*|packages/shared/*)
|
apps/web/*|packages/ui/*|packages/shared/*|pnpm-workspace.yaml|pnpm-lock.yaml)
|
||||||
web=true
|
web=true
|
||||||
;;
|
;;
|
||||||
deploy/bird/*)
|
deploy/bird/*)
|
||||||
@@ -170,15 +170,15 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Enable pnpm via corepack
|
- uses: pnpm/action-setup@v4
|
||||||
run: corepack enable
|
with:
|
||||||
- name: pnpm install, typecheck, lint, build
|
version: 10.33.2
|
||||||
|
- name: pnpm install, check, lint
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm --filter @evobgp/web run typecheck
|
pnpm --filter @evobgp/web check
|
||||||
pnpm --filter @evobgp/web run lint
|
pnpm --filter @evobgp/web lint
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
go:
|
go:
|
||||||
|
|||||||
+1
-8
@@ -1,13 +1,6 @@
|
|||||||
# Root npm/pnpm (semantic-release, commitlint, workspaces) — never commit deps
|
# Root npm (semantic-release, commitlint) — npm ci in CI, never commit deps
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Vite / TS build output
|
|
||||||
apps/web/dist/
|
|
||||||
packages/*/dist/
|
|
||||||
|
|
||||||
# TanStack Router auto-generated route tree
|
|
||||||
apps/web/src/routeTree.gen.ts
|
|
||||||
|
|
||||||
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
|
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
|
||||||
deploy/docker/docker-bake.override.hcl
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: prettier-web
|
- id: prettier-web
|
||||||
name: prettier (web)
|
name: prettier (web)
|
||||||
entry: bash -c 'cd web && npx prettier --check .'
|
entry: bash -c 'pnpm --filter @evobgp/web lint'
|
||||||
language: system
|
language: system
|
||||||
files: ^web/
|
files: ^apps/web/
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
@@ -1,74 +1,48 @@
|
|||||||
# Руководство для ИИ-агентов (экономия контекста)
|
# EvoBGP — руководство для ИИ-агентов
|
||||||
|
|
||||||
Краткие ориентиры по репозиторию **EvoBGP**, чтобы не тратить токены на полное сканирование дерева и повторное чтение одних и тех же файлов.
|
Краткая карта репозитория. Полная архитектура: [docs/architecture.md](docs/architecture.md). HTTP: [docs/openapi.yaml](docs/openapi.yaml).
|
||||||
|
|
||||||
## С чего начать (минимум чтения)
|
## Monorepo (фронтенд)
|
||||||
|
|
||||||
0. **Инженерные правила** — при изменении кода следовать [.cursor/rules/engineering.mdc](.cursor/rules/engineering.mdc); для `apps/web/` + `packages/ui/` — [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc); для `birdfmt` / `pipeline` / BIRD — [.cursor/rules/networking-bird.mdc](.cursor/rules/networking-bird.mdc). **Context7 (документация библиотек)** — закреплённые ID стека: [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc); скилл [.cursor/skills/context7-evobgp/SKILL.md](.cursor/skills/context7-evobgp/SKILL.md).
|
```
|
||||||
1. **[docs/README.md](docs/README.md)** — оглавление и роли читателя.
|
EvoBGP/
|
||||||
2. **[docs/architecture.md](docs/architecture.md)** — компоненты `cmd/`, карта `internal/`, потоки данных (одного этого файла обычно достаточно для ориентации).
|
├── cmd/, internal/ # Go API /v1
|
||||||
3. Задача-специфично: [docs/api.md](docs/api.md), [docs/access.md](docs/access.md) — только если меняете API или доступ.
|
├── apps/web/ # SvelteKit SPA (@evobgp/web)
|
||||||
|
├── packages/ui/ # @evobgp/ui — shadcn-svelte primitives
|
||||||
Источник правды по HTTP-контракту: **[docs/openapi.yaml](docs/openapi.yaml)**. Не дублируйте длинные фрагменты спецификации в ответах — ссылайтесь на путь и тег/операцию.
|
├── packages/shared/ # @evobgp/shared — types, Zod contracts
|
||||||
|
└── pnpm-workspace.yaml
|
||||||
## Карта кода (куда смотреть)
|
|
||||||
|
|
||||||
| Область | Где искать |
|
|
||||||
|---------|------------|
|
|
||||||
| REST, auth, CORS | `internal/httpapi/` |
|
|
||||||
| Бизнес-слой и абстракция хранилища | `internal/store/` |
|
|
||||||
| PostgreSQL | `internal/repository/`, `internal/db/`, `migrations/` |
|
|
||||||
| Фоновые задачи | `internal/jobs/` |
|
|
||||||
| Цепочка refresh модуля (ingest+render, BIRD preview) | `internal/pipeline/` |
|
|
||||||
| Конфиг BIRD, `birdc` | `internal/birdfmt/`, `internal/birddeploy/` |
|
|
||||||
| Бандлы и подписи | `internal/bundle/`, `internal/signing/` |
|
|
||||||
| Точки входа процессов | `cmd/*/` |
|
|
||||||
| Веб (SvelteKit) | `web/` |
|
|
||||||
| Compose, деплой | `deploy/compose/` |
|
|
||||||
|
|
||||||
Точки входа бинарников и их роли — в таблице в начале [docs/architecture.md](docs/architecture.md).
|
|
||||||
|
|
||||||
## Как не раздувать контекст
|
|
||||||
|
|
||||||
- **Сначала узкий поиск:** `grep`/поиск по символу или короткий семантический запрос по одной папке (`internal/httpapi/`, `internal/pipeline/`, …), а не чтение всех `.go` подряд.
|
|
||||||
- **Читайте файлы целиком только при необходимости:** большие файлы — с `offset`/`limit` или по найденным строкам.
|
|
||||||
- **Не подтягивайте в контекст:** `web/node_modules/`, сгенерированные артефакты сборки, бинарники, полный `openapi.html`, если достаточно `openapi.yaml`.
|
|
||||||
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
|
|
||||||
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
|
|
||||||
|
|
||||||
## Коммиты (Conventional Commits)
|
|
||||||
|
|
||||||
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
|
|
||||||
|
|
||||||
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
|
|
||||||
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
|
|
||||||
|
|
||||||
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
|
|
||||||
|
|
||||||
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
|
|
||||||
|
|
||||||
## Команды и среда
|
|
||||||
|
|
||||||
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
|
||||||
- Быстрый старт и переменные: [docs/quickstart.md](docs/quickstart.md), [README.md](README.md).
|
|
||||||
- **Go:** после правок — `gofmt -w`, `go vet ./...`, `scripts/lint-go.ps1` (как CI golangci-lint).
|
|
||||||
|
|
||||||
## Язык документации проекта
|
|
||||||
|
|
||||||
Пользовательская документация в `docs/` — преимущественно на русском. Комментарии и имена в коде — в существующем стиле репозитория.
|
|
||||||
|
|
||||||
## Frontend (React + shadcn/ui + ReUI)
|
|
||||||
|
|
||||||
При правках `apps/web/**` или `packages/ui/**` следуйте [.cursor/rules/web-shadcn.mdc](.cursor/rules/web-shadcn.mdc) (**WEB-19**): перед завершением задачи **обязательно**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pnpm --filter @evobgp/web run typecheck
|
|
||||||
pnpm --filter @evobgp/web run lint
|
|
||||||
pnpm --filter @evobgp/web run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
| Слой | Импорт |
|
||||||
|
|------|--------|
|
||||||
|
| Примитивы | `@evobgp/ui/components/*` |
|
||||||
|
| CSS | `@evobgp/ui/styles/globals.css` |
|
||||||
|
| Типы/контракты | `@evobgp/shared/types/*`, `@evobgp/shared/contracts/*` |
|
||||||
|
| Паттерны UI | `$lib/components/*`, `$lib/components/patterns/*` |
|
||||||
|
| Queries | `$lib/queries/*` (`@tanstack/svelte-query`) |
|
||||||
|
|
||||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
## С чего начать
|
||||||
|
|
||||||
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 стека.
|
1. **Правила:** [engineering.mdc](.cursor/rules/engineering.mdc), [web-shadcn.mdc](.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](.cursor/rules/frontend-monorepo.mdc)
|
||||||
|
2. **Фронт:** [apps/web/README.md](apps/web/README.md)
|
||||||
|
3. **Go:** `internal/httpapi/`, `internal/store/`, `internal/pipeline/`
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @evobgp/web dev
|
||||||
|
pnpm --filter @evobgp/web build
|
||||||
|
pnpm --filter @evobgp/web check
|
||||||
|
pnpm --filter @evobgp/web lint
|
||||||
|
```
|
||||||
|
|
||||||
|
После правок `apps/web/**` — **обязательно** `check` и `lint` (WEB-19).
|
||||||
|
|
||||||
|
## UX-эталон
|
||||||
|
|
||||||
|
vps-tracker: `PageShell`, `SectionCards`, `QueryState`, `DataTableCard`, sidebar-07 layout. ReUI не используется — Data Table на shadcn-svelte.
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
|
||||||
|
При запросе коммита: [conventional-commits.mdc](.cursor/rules/conventional-commits.mdc), скилл `.cursor/skills/commit-message/SKILL.md`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# EvoBGP
|
# EvoBGP
|
||||||
|
|
||||||
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
|
Control plane для управления префиксами, модулями ingest, ревизиями конфигурации BIRD и выкладкой на BGP-спикеры. Репозиторий включает HTTP API на Go, веб-интерфейс (`apps/web/`), CLI для реплик (`evobgp-node`), агент и Docker Compose для локального и эталонного развёртывания.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# EvoBGP WebUI (`@evobgp/web`)
|
||||||
|
|
||||||
|
SvelteKit-панель управления EvoBGP. Monorepo: `apps/web` + `packages/ui` + `packages/shared`.
|
||||||
|
|
||||||
|
Правила: [.cursor/rules/web-shadcn.mdc](../../.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](../../.cursor/rules/frontend-monorepo.mdc).
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
| Путь | Назначение |
|
||||||
|
| ------------------------------ | ------------------------------------------- |
|
||||||
|
| `packages/ui/src/components/` | shadcn-svelte примитивы (`@evobgp/ui`) |
|
||||||
|
| `src/lib/components/` | PageShell, QueryState, SectionCards, domain |
|
||||||
|
| `src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
|
||||||
|
| `src/lib/ui/app/` | PageHeader, nav, toast |
|
||||||
|
| `src/lib/queries/` | TanStack Query factories |
|
||||||
|
| `packages/shared/` | API types + Zod contracts |
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @evobgp/web dev
|
||||||
|
pnpm --filter @evobgp/web check
|
||||||
|
pnpm --filter @evobgp/web lint
|
||||||
|
pnpm --filter @evobgp/web build
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавление shadcn-svelte (из `apps/web/`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm dlx shadcn-svelte@latest add <component> -y -o
|
||||||
|
```
|
||||||
|
|
||||||
|
Тема: `packages/ui/src/styles/globals.css` → импорт в `src/routes/layout.css`.
|
||||||
|
|
||||||
|
## Проверка перед PR
|
||||||
|
|
||||||
|
`pnpm --filter @evobgp/web check` и `lint` — exit 0 (WEB-19).
|
||||||
+14
-21
@@ -1,23 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://www.shadcn-svelte.com/schema.json",
|
||||||
"style": "base-nova",
|
"tailwind": {
|
||||||
"rsc": false,
|
"css": "../../packages/ui/src/styles/globals.css",
|
||||||
"tsx": true,
|
"baseColor": "neutral"
|
||||||
"tailwind": {
|
},
|
||||||
"config": "",
|
"aliases": {
|
||||||
"css": "../../packages/ui/src/styles/globals.css",
|
"components": "$lib/components",
|
||||||
"baseColor": "neutral",
|
"utils": "$lib/utils",
|
||||||
"cssVariables": true
|
"ui": "@evobgp/ui/components",
|
||||||
},
|
"hooks": "$lib/hooks",
|
||||||
"iconLibrary": "lucide",
|
"lib": "$lib"
|
||||||
"registries": {
|
},
|
||||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
"typescript": true,
|
||||||
},
|
"iconLibrary": "lucide"
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"hooks": "@/hooks",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"utils": "@evobgp/ui/lib/utils",
|
|
||||||
"ui": "@evobgp/ui/components"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import globals from 'globals'
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/no-unused-expressions': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
||||||
'prefer-const': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['src/components/reui/**/*.{ts,tsx}'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru" class="">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
<title>EvoBGP</title>
|
|
||||||
<script>
|
|
||||||
// Anti-FOUC: apply persisted theme before paint (matches next-themes attribute="class")
|
|
||||||
try {
|
|
||||||
var t = localStorage.getItem('evobgp-theme');
|
|
||||||
var m = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
var dark = t === 'dark' || (!t || t === 'system') && m;
|
|
||||||
if (dark) document.documentElement.classList.add('dark');
|
|
||||||
} catch (e) {}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+46
-60
@@ -1,62 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "@evobgp/web",
|
"name": "@evobgp/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite dev",
|
||||||
"build": "tsr generate && tsc -b && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsr generate && tsc --noEmit",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"lint": "eslint .",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"test": "vitest run"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
},
|
"lint": "prettier --check .",
|
||||||
"dependencies": {
|
"format": "prettier --write ."
|
||||||
"@base-ui/react": "^1.0.0",
|
},
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"devDependencies": {
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@evobgp/ui": "workspace:*",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@types/node": "^22.15.0",
|
||||||
"@tanstack/react-router": "^1.130.2",
|
"formsnap": "^2.0.1",
|
||||||
"@tanstack/react-router-devtools": "^1.130.2",
|
"prettier": "^3.8.1",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"svelte": "^5.54.0",
|
||||||
"cmdk": "^1.1.1",
|
"svelte-check": "^4.4.2",
|
||||||
"date-fns": "^4.4.0",
|
"sveltekit-superforms": "^2.30.1",
|
||||||
"input-otp": "^1.4.2",
|
"tailwindcss": "^4.1.18",
|
||||||
"lucide-react": "^0.468.0",
|
"typescript": "^5.9.3",
|
||||||
"next-themes": "^0.4.6",
|
"vite": "^7.3.1"
|
||||||
"react": "^19.2.0",
|
},
|
||||||
"react-day-picker": "^10.0.1",
|
"dependencies": {
|
||||||
"react-dom": "^19.2.0",
|
"@evobgp/shared": "workspace:*",
|
||||||
"react-hook-form": "^7.60.0",
|
"@evobgp/ui": "workspace:*",
|
||||||
"recharts": "3.8.0",
|
"@tanstack/svelte-query": "^5.90.2",
|
||||||
"sonner": "^1.7.0",
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"zod": "^3.25.0"
|
"bits-ui": "^2.17.2",
|
||||||
},
|
"clsx": "^2.1.1",
|
||||||
"devDependencies": {
|
"svelte-sonner": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"@tanstack/router-plugin": "^1.130.0",
|
"tailwind-variants": "^3.2.2",
|
||||||
"@tanstack/router-cli": "^1.130.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"@types/react": "^19.2.7",
|
"zod": "^4.4.3"
|
||||||
"@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",
|
|
||||||
"tailwindcss": "^4.1.0",
|
|
||||||
"tw-animate-css": "^1.0.0",
|
|
||||||
"typescript": "^5.9.2",
|
|
||||||
"vite": "^7.3.1",
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" rx="7" fill="#0f172a"/>
|
|
||||||
<text x="16" y="22" font-family="ui-sans-serif,system-ui,sans-serif" font-size="18" font-weight="700" fill="#f8fafc" text-anchor="middle">B</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@evobgp/ui/components/alert-dialog'
|
|
||||||
import type { ReactElement, ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
|
||||||
trigger: ReactElement
|
|
||||||
title: string
|
|
||||||
description?: ReactNode
|
|
||||||
confirmLabel?: string
|
|
||||||
cancelLabel?: string
|
|
||||||
destructive?: boolean
|
|
||||||
onConfirm: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDialog({
|
|
||||||
trigger,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = 'Подтвердить',
|
|
||||||
cancelLabel = 'Отмена',
|
|
||||||
destructive,
|
|
||||||
onConfirm,
|
|
||||||
}: ConfirmDialogProps) {
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger render={trigger} />
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
||||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
icon?: ReactNode
|
|
||||||
action?: ReactNode
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="text-sm font-medium">{title}</p>
|
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
{action ? <div className="mt-2">{action}</div> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Boxes,
|
|
||||||
Network,
|
|
||||||
Cog,
|
|
||||||
ListChecks,
|
|
||||||
Activity,
|
|
||||||
Settings,
|
|
||||||
BookText,
|
|
||||||
KeyRound,
|
|
||||||
ServerCog,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarInset,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from '@evobgp/ui/components/sidebar'
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from '@evobgp/ui/components/breadcrumb'
|
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
|
|
||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
|
||||||
import type { ComponentType, ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon: ComponentType<{ className?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavGroup {
|
|
||||||
label: string
|
|
||||||
items: NavItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAV_GROUPS: NavGroup[] = [
|
|
||||||
{
|
|
||||||
label: 'Обзор',
|
|
||||||
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Маршрутизация',
|
|
||||||
items: [
|
|
||||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
|
||||||
{ to: '/network', label: 'Сеть', icon: Network },
|
|
||||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Операции',
|
|
||||||
items: [
|
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Система',
|
|
||||||
items: [
|
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
|
||||||
|
|
||||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
|
||||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const PARENT_ROUTE: Record<string, string> = {}
|
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
const activeItem =
|
|
||||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
|
||||||
ALL_NAV_ITEMS[0]
|
|
||||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
|
||||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<Sidebar collapsible="icon">
|
|
||||||
<SidebarHeader>
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
|
||||||
B
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
|
||||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
{NAV_GROUPS.map((group) => (
|
|
||||||
<SidebarGroup key={group.label}>
|
|
||||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{group.items.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.to}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
render={<Link to={item.to} />}
|
|
||||||
isActive={isActive}
|
|
||||||
tooltip={item.label}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
))}
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarFooter />
|
|
||||||
</Sidebar>
|
|
||||||
<SidebarInset>
|
|
||||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
|
||||||
<SidebarTrigger />
|
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
{parentLabel && parentTo ? (
|
|
||||||
<>
|
|
||||||
<BreadcrumbItem className="hidden md:block">
|
|
||||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Loader2Icon } from 'lucide-react'
|
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
||||||
|
|
||||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
loading?: boolean
|
|
||||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
|
||||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button disabled={disabled || loading} {...props}>
|
|
||||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Moon, Sun } from 'lucide-react'
|
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@evobgp/ui/components/dropdown-menu'
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
|
||||||
const { setTheme } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
|
|
||||||
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
|
||||||
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
|
||||||
<span className="sr-only">Сменить тему</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
actions?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
|
||||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|
||||||
import { EmptyState } from './empty-state'
|
|
||||||
|
|
||||||
interface QueryStateProps<T> {
|
|
||||||
data: T | undefined
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
error?: unknown
|
|
||||||
empty?: boolean
|
|
||||||
emptyTitle?: string
|
|
||||||
emptyDescription?: string
|
|
||||||
emptyAction?: ReactNode
|
|
||||||
onRetry?: () => void
|
|
||||||
skeleton?: ReactNode
|
|
||||||
children: (data: T) => ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QueryState<T>({
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
empty,
|
|
||||||
emptyTitle = 'Нет данных',
|
|
||||||
emptyDescription,
|
|
||||||
emptyAction,
|
|
||||||
onRetry,
|
|
||||||
skeleton,
|
|
||||||
children,
|
|
||||||
}: QueryStateProps<T>) {
|
|
||||||
if (isLoading) {
|
|
||||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={<AlertCircle className="size-8" />}
|
|
||||||
title="Ошибка загрузки"
|
|
||||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
|
||||||
action={
|
|
||||||
onRetry ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
|
||||||
Повторить
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (empty || data == null) {
|
|
||||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
|
||||||
}
|
|
||||||
return <>{children(data)}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function DefaultSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
|
||||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const inputVariants = cva(
|
|
||||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
|
||||||
default:
|
|
||||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
|
||||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Autocomplete = AutocompletePrimitive.Root
|
|
||||||
|
|
||||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteInput({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
showClear = false,
|
|
||||||
showTrigger = false,
|
|
||||||
...props
|
|
||||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
|
||||||
VariantProps<typeof inputVariants> & {
|
|
||||||
showClear?: boolean
|
|
||||||
showTrigger?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<AutocompletePrimitive.Input
|
|
||||||
data-slot="autocomplete-input"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(inputVariants({ size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{showTrigger && <AutocompleteTrigger />}
|
|
||||||
{showClear && <AutocompleteClear />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteStatus({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AutocompletePrimitive.Status.Props) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Status
|
|
||||||
data-slot="autocomplete-status"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteBackdrop({
|
|
||||||
...props
|
|
||||||
}: AutocompletePrimitive.Backdrop.Props) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Backdrop
|
|
||||||
data-slot="autocomplete-backdrop"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompletePositioner({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AutocompletePrimitive.Positioner.Props) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Positioner
|
|
||||||
data-slot="autocomplete-positioner"
|
|
||||||
className={cn("z-50 outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteList({
|
|
||||||
className,
|
|
||||||
scrollAreaClassName,
|
|
||||||
...props
|
|
||||||
}: AutocompletePrimitive.List.Props & {
|
|
||||||
scrollAreaClassName?: string
|
|
||||||
scrollFade?: boolean
|
|
||||||
scrollbarGutter?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ScrollArea
|
|
||||||
className={cn(
|
|
||||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
|
||||||
scrollAreaClassName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AutocompletePrimitive.List
|
|
||||||
data-slot="autocomplete-list"
|
|
||||||
className={cn(
|
|
||||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteCollection({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Collection
|
|
||||||
data-slot="autocomplete-collection"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteRow({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Row
|
|
||||||
data-slot="autocomplete-row"
|
|
||||||
className={cn("flex items-center gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Item
|
|
||||||
data-slot="autocomplete-item"
|
|
||||||
className={cn(
|
|
||||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
|
||||||
typeof AutocompletePrimitive.Popup
|
|
||||||
> {
|
|
||||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
|
||||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
|
||||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
|
||||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
|
||||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
|
||||||
showBackdrop?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showBackdrop = false,
|
|
||||||
align = "start",
|
|
||||||
sideOffset = 4,
|
|
||||||
alignOffset = 0,
|
|
||||||
side = "bottom",
|
|
||||||
anchor,
|
|
||||||
...props
|
|
||||||
}: AutocompleteContentProps) {
|
|
||||||
return (
|
|
||||||
<AutocompletePortal>
|
|
||||||
{showBackdrop && <AutocompleteBackdrop />}
|
|
||||||
<AutocompletePositioner
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
alignOffset={alignOffset}
|
|
||||||
side={side}
|
|
||||||
anchor={anchor}
|
|
||||||
>
|
|
||||||
<div className="relative flex max-h-full">
|
|
||||||
<AutocompletePrimitive.Popup
|
|
||||||
data-slot="autocomplete-popup"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AutocompletePrimitive.Popup>
|
|
||||||
</div>
|
|
||||||
</AutocompletePositioner>
|
|
||||||
</AutocompletePortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteGroupLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.GroupLabel
|
|
||||||
data-slot="autocomplete-group-label"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteEmpty({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Empty
|
|
||||||
data-slot="autocomplete-empty"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteClear({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Clear
|
|
||||||
data-slot="autocomplete-clear"
|
|
||||||
className={cn(
|
|
||||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</AutocompletePrimitive.Clear>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Trigger
|
|
||||||
data-slot="autocomplete-trigger"
|
|
||||||
className={cn(
|
|
||||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
|
||||||
</AutocompletePrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteArrow({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutocompleteSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<AutocompletePrimitive.Separator
|
|
||||||
data-slot="autocomplete-separator"
|
|
||||||
className={cn(
|
|
||||||
"bg-border my-1.5 h-px",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Autocomplete,
|
|
||||||
AutocompleteValue,
|
|
||||||
AutocompleteTrigger,
|
|
||||||
AutocompleteInput,
|
|
||||||
AutocompleteStatus,
|
|
||||||
AutocompletePortal,
|
|
||||||
AutocompleteBackdrop,
|
|
||||||
AutocompletePositioner,
|
|
||||||
AutocompleteContent,
|
|
||||||
AutocompleteList,
|
|
||||||
AutocompleteCollection,
|
|
||||||
AutocompleteRow,
|
|
||||||
AutocompleteItem,
|
|
||||||
AutocompleteGroup,
|
|
||||||
AutocompleteGroupLabel,
|
|
||||||
AutocompleteEmpty,
|
|
||||||
AutocompleteClear,
|
|
||||||
AutocompleteArrow,
|
|
||||||
AutocompleteSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground",
|
|
||||||
outline: "border-border bg-transparent dark:bg-input/32",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground",
|
|
||||||
info: "bg-info text-white",
|
|
||||||
success: "bg-success text-white",
|
|
||||||
warning: "bg-warning text-white",
|
|
||||||
destructive: "bg-destructive text-white",
|
|
||||||
focus: "bg-focus text-focus-foreground",
|
|
||||||
invert: "bg-invert text-invert-foreground",
|
|
||||||
"primary-light":
|
|
||||||
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
|
||||||
"warning-light":
|
|
||||||
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
|
||||||
"success-light":
|
|
||||||
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
|
||||||
"info-light":
|
|
||||||
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
|
||||||
"destructive-light":
|
|
||||||
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
|
||||||
"invert-light":
|
|
||||||
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
|
||||||
"focus-light":
|
|
||||||
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
|
||||||
"primary-outline":
|
|
||||||
"bg-background border-border text-primary dark:bg-input/30",
|
|
||||||
"warning-outline":
|
|
||||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
|
||||||
"success-outline":
|
|
||||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
|
||||||
"info-outline":
|
|
||||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
|
||||||
"destructive-outline":
|
|
||||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
|
||||||
"invert-outline":
|
|
||||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
|
||||||
"focus-outline":
|
|
||||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
|
||||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
|
||||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
|
||||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
|
||||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
|
||||||
},
|
|
||||||
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
|
||||||
radius: {
|
|
||||||
default:
|
|
||||||
"rounded-sm",
|
|
||||||
full: "rounded-full",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
radius: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
|
||||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
|
||||||
size?: VariantProps<typeof badgeVariants>["size"]
|
|
||||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
|
||||||
}
|
|
||||||
|
|
||||||
function Badge({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
radius,
|
|
||||||
render,
|
|
||||||
...props
|
|
||||||
}: BadgeProps) {
|
|
||||||
const defaultProps = {
|
|
||||||
"data-slot": "badge",
|
|
||||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
|
||||||
}
|
|
||||||
|
|
||||||
return useRender({
|
|
||||||
defaultTagName: "span",
|
|
||||||
render,
|
|
||||||
props: mergeProps<"span">(defaultProps, props),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants, type BadgeProps }
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { Column } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { Input } from "@evobgp/ui/components/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@evobgp/ui/components/popover"
|
|
||||||
import { Separator } from "@evobgp/ui/components/separator"
|
|
||||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridColumnFilterProps<TData, TValue> {
|
|
||||||
column?: Column<TData, TValue>
|
|
||||||
title?: string
|
|
||||||
options: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
icon?: React.ComponentType<{ className?: string }>
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridColumnFilter<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
options,
|
|
||||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
|
||||||
const facets = column?.getFacetedUniqueValues()
|
|
||||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
|
||||||
if (!searchQuery) return options
|
|
||||||
return options.filter((option) =>
|
|
||||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
}, [options, searchQuery])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<CirclePlusIcon className="size-4" />
|
|
||||||
{title}
|
|
||||||
{selectedValues?.size > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-sm px-1 font-normal lg:hidden"
|
|
||||||
>
|
|
||||||
{selectedValues.size}
|
|
||||||
</Badge>
|
|
||||||
<div className="hidden space-x-1 lg:flex">
|
|
||||||
{selectedValues.size > 2 ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-sm px-1 font-normal"
|
|
||||||
>
|
|
||||||
{selectedValues.size} selected
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
options
|
|
||||||
.filter((option) => selectedValues.has(option.value))
|
|
||||||
.map((option) => (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
key={option.value}
|
|
||||||
className="rounded-sm px-1 font-normal"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
|
||||||
<div className="p-2">
|
|
||||||
<Input
|
|
||||||
placeholder={title}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
|
||||||
{filteredOptions.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
|
||||||
No results found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-1">
|
|
||||||
{filteredOptions.map((option) => {
|
|
||||||
const isSelected = selectedValues.has(option.value)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => {
|
|
||||||
if (isSelected) {
|
|
||||||
selectedValues.delete(option.value)
|
|
||||||
} else {
|
|
||||||
selectedValues.add(option.value)
|
|
||||||
}
|
|
||||||
const filterValues = Array.from(selectedValues)
|
|
||||||
column?.setFilterValue(
|
|
||||||
filterValues.length ? filterValues : undefined
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "opacity-50 [&_svg]:invisible"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
{option.icon && (
|
|
||||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{facets?.get(option.value) && (
|
|
||||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
|
||||||
{facets.get(option.value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedValues.size > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="bg-border -mx-1 my-1 h-px" />
|
|
||||||
<div className="p-1">
|
|
||||||
<div
|
|
||||||
onClick={() => column?.setFilterValue(undefined)}
|
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
|
||||||
import {
|
|
||||||
getColumnHeaderLabel,
|
|
||||||
useDataGrid,
|
|
||||||
} from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { Column } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridColumnHeaderProps<
|
|
||||||
TData,
|
|
||||||
TValue,
|
|
||||||
> extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
column: Column<TData, TValue>
|
|
||||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
|
||||||
title?: string
|
|
||||||
icon?: ReactNode
|
|
||||||
pinnable?: boolean
|
|
||||||
filter?: ReactNode
|
|
||||||
visibility?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridColumnHeaderInner<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
className,
|
|
||||||
filter,
|
|
||||||
visibility = false,
|
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
|
||||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
|
||||||
|
|
||||||
const columnOrder = table.getState().columnOrder
|
|
||||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
const isPinned = column.getIsPinned()
|
|
||||||
const canSort = column.getCanSort()
|
|
||||||
const canPin = column.getCanPin()
|
|
||||||
const canResize = column.getCanResize()
|
|
||||||
|
|
||||||
const columnIndex = columnOrder.indexOf(column.id)
|
|
||||||
const canMoveLeft = columnIndex > 0
|
|
||||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
|
||||||
|
|
||||||
const handleSort = () => {
|
|
||||||
if (isSorted === "asc") {
|
|
||||||
column.toggleSorting(true)
|
|
||||||
} else if (isSorted === "desc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerLabelClassName = cn(
|
|
||||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
const headerButtonClassName = cn(
|
|
||||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortIcon =
|
|
||||||
canSort &&
|
|
||||||
(isSorted === "desc" ? (
|
|
||||||
<ArrowDownIcon className="size-3.25" />
|
|
||||||
) : isSorted === "asc" ? (
|
|
||||||
<ArrowUpIcon className="size-3.25" />
|
|
||||||
) : (
|
|
||||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
|
||||||
))
|
|
||||||
|
|
||||||
const hasControls =
|
|
||||||
props.tableLayout?.columnsMovable ||
|
|
||||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
|
||||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
|
||||||
filter
|
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
|
||||||
const items: ReactNode[] = []
|
|
||||||
let hasPreviousSection = false
|
|
||||||
|
|
||||||
// Filter section
|
|
||||||
if (filter) {
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuGroup key="group-filter">
|
|
||||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort section
|
|
||||||
if (canSort) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="sort-asc"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSorted === "asc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canSort}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="size-3.5!" />
|
|
||||||
<span className="grow">Asc</span>
|
|
||||||
{isSorted === "asc" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="sort-desc"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSorted === "desc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canSort}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="size-3.5!" />
|
|
||||||
<span className="grow">Desc</span>
|
|
||||||
{isSorted === "desc" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin section
|
|
||||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="pin-left"
|
|
||||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
|
||||||
>
|
|
||||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span className="grow">Pin to left</span>
|
|
||||||
{isPinned === "left" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="pin-right"
|
|
||||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
|
||||||
>
|
|
||||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span className="grow">Pin to right</span>
|
|
||||||
{isPinned === "right" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move section
|
|
||||||
if (props.tableLayout?.columnsMovable) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="move-left"
|
|
||||||
onClick={() => {
|
|
||||||
if (columnIndex > 0) {
|
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canMoveLeft || isPinned !== false}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span>Move to Left</span>
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="move-right"
|
|
||||||
onClick={() => {
|
|
||||||
if (columnIndex < columnOrder.length - 1) {
|
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canMoveRight || isPinned !== false}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span>Move to Right</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visibility section
|
|
||||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuSub key="visibility">
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<Settings2Icon className="size-3.5!" />
|
|
||||||
<span>Columns</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent side="right">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((col) => col.getCanHide())
|
|
||||||
.map((col) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={col.id}
|
|
||||||
checked={col.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(col)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
|
||||||
filter,
|
|
||||||
canSort,
|
|
||||||
isSorted,
|
|
||||||
column,
|
|
||||||
props.tableLayout?.columnsPinnable,
|
|
||||||
props.tableLayout?.columnsMovable,
|
|
||||||
props.tableLayout?.columnsVisibility,
|
|
||||||
canPin,
|
|
||||||
isPinned,
|
|
||||||
canMoveLeft,
|
|
||||||
canMoveRight,
|
|
||||||
visibility,
|
|
||||||
table,
|
|
||||||
columnIndex,
|
|
||||||
columnOrder,
|
|
||||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
|
||||||
])
|
|
||||||
|
|
||||||
if (hasControls) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-between gap-1.5">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={headerButtonClassName}
|
|
||||||
disabled={isLoading || recordCount === 0}
|
|
||||||
>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
{sortIcon}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent className="w-40" align="start">
|
|
||||||
{menuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="-me-1 size-7 rounded-md"
|
|
||||||
onClick={() => column.pin(false)}
|
|
||||||
aria-label={`Unpin ${resolvedTitle} column`}
|
|
||||||
title={`Unpin ${resolvedTitle} column`}
|
|
||||||
>
|
|
||||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={headerButtonClassName}
|
|
||||||
disabled={isLoading || recordCount === 0}
|
|
||||||
onClick={handleSort}
|
|
||||||
>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
{sortIcon}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={headerLabelClassName}>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataGridColumnHeader = memo(
|
|
||||||
DataGridColumnHeaderInner
|
|
||||||
) as typeof DataGridColumnHeaderInner
|
|
||||||
|
|
||||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { ReactElement } from "react"
|
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { Table } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evobgp/ui/components/dropdown-menu"
|
|
||||||
|
|
||||||
function DataGridColumnVisibility<TData>({
|
|
||||||
table,
|
|
||||||
trigger,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={trigger} />
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-medium">
|
|
||||||
Toggle Columns
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(column)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnVisibility }
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import React, { ReactNode } from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridPaginationProps {
|
|
||||||
sizes?: number[]
|
|
||||||
sizesInfo?: string
|
|
||||||
sizesLabel?: string
|
|
||||||
sizesDescription?: string
|
|
||||||
sizesSkeleton?: ReactNode
|
|
||||||
more?: boolean
|
|
||||||
moreLimit?: number
|
|
||||||
info?: string
|
|
||||||
infoSkeleton?: ReactNode
|
|
||||||
className?: string
|
|
||||||
rowsPerPageLabel?: string
|
|
||||||
previousPageLabel?: string
|
|
||||||
nextPageLabel?: string
|
|
||||||
ellipsisText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|
||||||
const { table, recordCount, isLoading } = useDataGrid()
|
|
||||||
|
|
||||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
|
||||||
sizes: [5, 10, 25, 50, 100],
|
|
||||||
sizesLabel: "Show",
|
|
||||||
sizesDescription: "per page",
|
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
|
||||||
moreLimit: 5,
|
|
||||||
more: false,
|
|
||||||
info: "{from} - {to} of {count}",
|
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
|
||||||
rowsPerPageLabel: "Rows per page",
|
|
||||||
previousPageLabel: "Go to previous page",
|
|
||||||
nextPageLabel: "Go to next page",
|
|
||||||
ellipsisText: "...",
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
|
||||||
|
|
||||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
|
||||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
|
||||||
const pageIndex = table.getState().pagination.pageIndex
|
|
||||||
const pageSize = table.getState().pagination.pageSize
|
|
||||||
const from = pageIndex * pageSize + 1
|
|
||||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
|
||||||
const pageCount = table.getPageCount()
|
|
||||||
|
|
||||||
// Replace placeholders in paginationInfo
|
|
||||||
const paginationInfo = mergedProps?.info
|
|
||||||
? mergedProps.info
|
|
||||||
.replace("{from}", from.toString())
|
|
||||||
.replace("{to}", to.toString())
|
|
||||||
.replace("{count}", recordCount.toString())
|
|
||||||
: `${from} - ${to} of ${recordCount}`
|
|
||||||
|
|
||||||
// Pagination limit logic
|
|
||||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
|
||||||
|
|
||||||
// Determine the start and end of the pagination group
|
|
||||||
const currentGroupStart =
|
|
||||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
|
||||||
const currentGroupEnd = Math.min(
|
|
||||||
currentGroupStart + paginationMoreLimit,
|
|
||||||
pageCount
|
|
||||||
)
|
|
||||||
|
|
||||||
// Render page buttons based on the current group
|
|
||||||
const renderPageButtons = () => {
|
|
||||||
const buttons = []
|
|
||||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
|
||||||
buttons.push(
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
|
||||||
"bg-accent text-accent-foreground": pageIndex === i,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex !== i) {
|
|
||||||
table.setPageIndex(i)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "previous" ellipsis button if there are previous pages to show
|
|
||||||
const renderEllipsisPrevButton = () => {
|
|
||||||
if (currentGroupStart > 0) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
|
||||||
const renderEllipsisNextButton = () => {
|
|
||||||
if (currentGroupEnd < pageCount) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="data-grid-pagination"
|
|
||||||
className={cn(
|
|
||||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
|
||||||
mergedProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
|
||||||
{isLoading ? (
|
|
||||||
mergedProps?.sizesSkeleton
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{mergedProps.rowsPerPageLabel}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={`${pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newPageSize = Number(value)
|
|
||||||
table.setPageSize(newPageSize)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-14" size="sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top" className="min-w-18">
|
|
||||||
{mergedProps?.sizes?.map((size: number) => (
|
|
||||||
<SelectItem key={size} value={`${size}`}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
|
||||||
{isLoading ? (
|
|
||||||
mergedProps?.infoSkeleton
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
|
||||||
{paginationInfo}
|
|
||||||
</div>
|
|
||||||
{pageCount > 1 && (
|
|
||||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={btnArrowClasses}
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
{mergedProps.previousPageLabel}
|
|
||||||
</span>
|
|
||||||
<ChevronLeftIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{renderEllipsisPrevButton()}
|
|
||||||
|
|
||||||
{renderPageButtons()}
|
|
||||||
|
|
||||||
{renderEllipsisNextButton()}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={btnArrowClasses}
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
|
||||||
<ChevronRightIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridPagination, type DataGridPaginationProps }
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
PointerEvent,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
const MIN_THUMB_SIZE = 24
|
|
||||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
|
||||||
|
|
||||||
const INITIAL_METRICS = {
|
|
||||||
hasVerticalOverflow: false,
|
|
||||||
headerHeight: 0,
|
|
||||||
horizontalScrollbarSize: 0,
|
|
||||||
thumbHeight: 0,
|
|
||||||
thumbTop: 0,
|
|
||||||
trackHeight: 0,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
|
||||||
|
|
||||||
type ScrollbarMetrics = {
|
|
||||||
hasVerticalOverflow: boolean
|
|
||||||
headerHeight: number
|
|
||||||
horizontalScrollbarSize: number
|
|
||||||
thumbHeight: number
|
|
||||||
thumbTop: number
|
|
||||||
trackHeight: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ObservedElements = {
|
|
||||||
header: HTMLElement | null
|
|
||||||
horizontalScrollbar: HTMLElement | null
|
|
||||||
table: HTMLElement | null
|
|
||||||
tableViewport: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridScrollAreaProps = Omit<
|
|
||||||
ScrollAreaPrimitive.Root.Props,
|
|
||||||
"children"
|
|
||||||
> & {
|
|
||||||
children: ReactNode
|
|
||||||
orientation?: DataGridScrollAreaOrientation
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(max, Math.max(min, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
|
||||||
return (
|
|
||||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
|
||||||
next.headerHeight === prev.headerHeight &&
|
|
||||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
|
||||||
next.thumbHeight === prev.thumbHeight &&
|
|
||||||
next.thumbTop === prev.thumbTop &&
|
|
||||||
next.trackHeight === prev.trackHeight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-header-height",
|
|
||||||
`${metrics.headerHeight}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-thumb-height",
|
|
||||||
`${metrics.thumbHeight}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-thumb-top",
|
|
||||||
`${metrics.thumbTop}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-track-height",
|
|
||||||
`${metrics.trackHeight}px`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridScrollArea({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
orientation = "both",
|
|
||||||
...props
|
|
||||||
}: DataGridScrollAreaProps) {
|
|
||||||
const { props: dataGridProps } = useDataGrid()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const dragRef = useRef<{
|
|
||||||
pointerId: number
|
|
||||||
startScrollTop: number
|
|
||||||
startY: number
|
|
||||||
} | null>(null)
|
|
||||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
|
||||||
const observedElementsRef = useRef<ObservedElements>({
|
|
||||||
header: null,
|
|
||||||
horizontalScrollbar: null,
|
|
||||||
table: null,
|
|
||||||
tableViewport: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const showHorizontal = orientation !== "vertical"
|
|
||||||
const showVertical = orientation !== "horizontal"
|
|
||||||
const usesCustomVerticalScrollbar =
|
|
||||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
|
||||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
|
||||||
useState(false)
|
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
|
||||||
dragRef.current = null
|
|
||||||
document.body.style.userSelect = ""
|
|
||||||
document.body.style.webkitUserSelect = ""
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resetMetrics = useCallback(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
|
|
||||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
|
||||||
applyMetrics(container, INITIAL_METRICS)
|
|
||||||
metricsRef.current = INITIAL_METRICS
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
|
||||||
resetMetrics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
|
||||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
|
||||||
const viewportHeight = viewport.clientHeight
|
|
||||||
const viewportWidth = viewport.clientWidth
|
|
||||||
const scrollHeight = viewport.scrollHeight
|
|
||||||
const scrollWidth = viewport.scrollWidth
|
|
||||||
const hasHorizontalOverflow =
|
|
||||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
|
||||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
|
||||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
|
||||||
: 0
|
|
||||||
const trackHeight = Math.max(
|
|
||||||
0,
|
|
||||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
|
||||||
)
|
|
||||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
|
||||||
|
|
||||||
let nextMetrics: ScrollbarMetrics
|
|
||||||
|
|
||||||
if (trackHeight === 0 || maxScroll === 0) {
|
|
||||||
nextMetrics = {
|
|
||||||
hasVerticalOverflow: false,
|
|
||||||
headerHeight,
|
|
||||||
horizontalScrollbarSize,
|
|
||||||
thumbHeight: trackHeight,
|
|
||||||
thumbTop: 0,
|
|
||||||
trackHeight,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const bodyContentHeight = Math.max(
|
|
||||||
trackHeight,
|
|
||||||
scrollHeight - headerHeight
|
|
||||||
)
|
|
||||||
const thumbHeight = clamp(
|
|
||||||
trackHeight * (trackHeight / bodyContentHeight),
|
|
||||||
MIN_THUMB_SIZE,
|
|
||||||
trackHeight
|
|
||||||
)
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
const thumbTop =
|
|
||||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
|
||||||
|
|
||||||
nextMetrics = {
|
|
||||||
hasVerticalOverflow: true,
|
|
||||||
headerHeight,
|
|
||||||
horizontalScrollbarSize,
|
|
||||||
thumbHeight,
|
|
||||||
thumbTop,
|
|
||||||
trackHeight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
|
||||||
applyMetrics(container, nextMetrics)
|
|
||||||
metricsRef.current = nextMetrics
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) =>
|
|
||||||
prev === nextMetrics.hasVerticalOverflow
|
|
||||||
? prev
|
|
||||||
: nextMetrics.hasVerticalOverflow
|
|
||||||
)
|
|
||||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!container || !viewport) return
|
|
||||||
|
|
||||||
if (!usesCustomVerticalScrollbar) {
|
|
||||||
resetMetrics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
observedElementsRef.current = {
|
|
||||||
header: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"] thead'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
horizontalScrollbar: container.querySelector(
|
|
||||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
table: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
tableViewport: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table-viewport"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame = 0
|
|
||||||
|
|
||||||
const scheduleSync = () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSync()
|
|
||||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
|
||||||
|
|
||||||
const observer =
|
|
||||||
typeof ResizeObserver === "undefined"
|
|
||||||
? null
|
|
||||||
: new ResizeObserver(scheduleSync)
|
|
||||||
|
|
||||||
observer?.observe(viewport)
|
|
||||||
observedElementsRef.current.header &&
|
|
||||||
observer?.observe(observedElementsRef.current.header)
|
|
||||||
observedElementsRef.current.table &&
|
|
||||||
observer?.observe(observedElementsRef.current.table)
|
|
||||||
observedElementsRef.current.tableViewport &&
|
|
||||||
observer?.observe(observedElementsRef.current.tableViewport)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
observer?.disconnect()
|
|
||||||
viewport.removeEventListener("scroll", scheduleSync)
|
|
||||||
clearDragState()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
clearDragState,
|
|
||||||
resetMetrics,
|
|
||||||
syncCustomVerticalScrollbar,
|
|
||||||
usesCustomVerticalScrollbar,
|
|
||||||
])
|
|
||||||
|
|
||||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
const { thumbHeight, trackHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (!viewport) return
|
|
||||||
|
|
||||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
|
|
||||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
|
||||||
viewport.scrollTop = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
|
||||||
viewport.scrollTop = ratio * maxScroll
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!viewport) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startScrollTop: viewport.scrollTop,
|
|
||||||
startY: event.clientY,
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.style.userSelect = "none"
|
|
||||||
document.body.style.webkitUserSelect = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
const dragState = dragRef.current
|
|
||||||
const { thumbHeight, trackHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
|
||||||
|
|
||||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
|
||||||
|
|
||||||
const deltaY = event.clientY - dragState.startY
|
|
||||||
const nextScrollTop =
|
|
||||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
|
||||||
|
|
||||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
|
||||||
clearDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const { thumbHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (event.target !== event.currentTarget) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
|
||||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
|
||||||
|
|
||||||
scrollToThumbOffset(offsetY)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative">
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
data-slot="data-grid-scroll-area"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport
|
|
||||||
ref={viewportRef}
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
className="size-full"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Content>
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
|
|
||||||
{showHorizontal && (
|
|
||||||
<ScrollAreaPrimitive.Scrollbar
|
|
||||||
data-slot="data-grid-scrollbar"
|
|
||||||
data-orientation="horizontal"
|
|
||||||
orientation="horizontal"
|
|
||||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Thumb
|
|
||||||
data-slot="data-grid-thumb"
|
|
||||||
className="bg-border rounded-full relative flex-1"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showVertical && !usesCustomVerticalScrollbar && (
|
|
||||||
<ScrollAreaPrimitive.Scrollbar
|
|
||||||
data-slot="data-grid-scrollbar"
|
|
||||||
data-orientation="vertical"
|
|
||||||
orientation="vertical"
|
|
||||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Thumb
|
|
||||||
data-slot="data-grid-thumb"
|
|
||||||
className="bg-border rounded-full relative flex-1"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
|
||||||
)}
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
|
|
||||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
|
||||||
onPointerDown={handleTrackPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-border absolute end-px w-2",
|
|
||||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
|
||||||
"rounded-full"
|
|
||||||
)}
|
|
||||||
onLostPointerCapture={clearDragState}
|
|
||||||
onPointerCancel={handleThumbPointerUp}
|
|
||||||
onPointerDown={handleThumbPointerDown}
|
|
||||||
onPointerMove={handleThumbPointerMove}
|
|
||||||
onPointerUp={handleThumbPointerUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridScrollArea }
|
|
||||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
CSSProperties,
|
|
||||||
ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
UniqueIdentifier,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
type Modifier,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { GripHorizontalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Context to share sortable listeners from row to handle
|
|
||||||
type SortableContextValue = ReturnType<typeof useSortable>
|
|
||||||
const SortableRowContext = createContext<Pick<
|
|
||||||
SortableContextValue,
|
|
||||||
"attributes" | "listeners"
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|
||||||
const context = useContext(SortableRowContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...context.attributes}
|
|
||||||
{...context.listeners}
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
|
||||||
const {
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
setNodeRef,
|
|
||||||
isDragging,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
} = useSortable({
|
|
||||||
id: row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition,
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
position: "relative",
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
|
||||||
<DataGridTableBodyRow
|
|
||||||
row={row}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
dndStyle={style}
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
</SortableRowContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRows<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
dataIds,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
useSensor(KeyboardSensor, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingRow) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingRow])
|
|
||||||
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableContainer: Modifier = ({
|
|
||||||
transform,
|
|
||||||
draggingNodeRect,
|
|
||||||
}) => {
|
|
||||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
|
||||||
return transform
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
|
||||||
const { x, y } = transform
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left
|
|
||||||
const maxX = containerRect.right - draggingNodeRect.right
|
|
||||||
const minY = containerRect.top - draggingNodeRect.top
|
|
||||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.max(minX, Math.min(maxX, x)),
|
|
||||||
y: Math.max(minY, Math.min(maxY, y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id={useId()}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingRow(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingRow(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingRow(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={tableContainerRef}
|
|
||||||
className={
|
|
||||||
isDraggingRow
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
{headerGroup.headers.map((header, index) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={index}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
{props.loadingMode === "skeleton" &&
|
|
||||||
isLoading &&
|
|
||||||
pagination?.pageSize ? (
|
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
<SortableContext
|
|
||||||
items={dataIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return <DataGridTableDndRow row={row} key={row.id} />
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
Fragment,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
Modifier,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
Header,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DataGridTableDndHeader<TData>({
|
|
||||||
header,
|
|
||||||
}: {
|
|
||||||
header: Header<TData, unknown>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
|
||||||
const canOrder =
|
|
||||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
|
||||||
.enableColumnOrdering !== false
|
|
||||||
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isDragging,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({
|
|
||||||
id: header.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
|
||||||
: header.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell
|
|
||||||
header={header}
|
|
||||||
dndStyle={style}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-0.5">
|
|
||||||
{canOrder && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<span className="grow truncate">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</span>
|
|
||||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
|
||||||
id: cell.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
|
||||||
: cell.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDnd<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
useSensor(KeyboardSensor, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingColumn) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingColumn])
|
|
||||||
|
|
||||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
|
||||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
|
||||||
if (!draggingNodeRect || !containerRef.current) {
|
|
||||||
return { ...transform, y: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect()
|
|
||||||
const edgeOffset = 0
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
|
||||||
const maxX =
|
|
||||||
containerRect.right -
|
|
||||||
draggingNodeRect.left -
|
|
||||||
draggingNodeRect.width +
|
|
||||||
edgeOffset
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
|
||||||
y: 0, // Lock vertical movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
id={useId()}
|
|
||||||
modifiers={[restrictToTableBounds]}
|
|
||||||
onDragCancel={() => setIsDraggingColumn(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingColumn(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingColumn(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={containerRef}
|
|
||||||
className={
|
|
||||||
isDraggingColumn
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataGridTableDndHeader
|
|
||||||
header={header}
|
|
||||||
key={header.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
{props.loadingMode === "skeleton" &&
|
|
||||||
isLoading &&
|
|
||||||
pagination?.pageSize ? (
|
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.id}>
|
|
||||||
<DataGridTableBodyRow row={row}>
|
|
||||||
{row
|
|
||||||
.getVisibleCells()
|
|
||||||
.map((cell: Cell<TData, unknown>) => {
|
|
||||||
return (
|
|
||||||
<SortableContext
|
|
||||||
key={cell.id}
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<DataGridTableDndCell cell={cell} />
|
|
||||||
</SortableContext>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && (
|
|
||||||
<DataGridTableBodyRowExpandded row={row} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDnd }
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
import {
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRenderedRow,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
getDataGridTableRowSections,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
|
||||||
import {
|
|
||||||
useVirtualizer,
|
|
||||||
VirtualItem,
|
|
||||||
Virtualizer,
|
|
||||||
VirtualizerOptions,
|
|
||||||
} from "@tanstack/react-virtual"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
|
||||||
|
|
||||||
type DataGridTableVirtualScrollElements = {
|
|
||||||
containerElement: HTMLDivElement | null
|
|
||||||
scrollElement: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
|
||||||
HTMLElement,
|
|
||||||
HTMLTableRowElement
|
|
||||||
>
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
|
||||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
|
||||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
|
||||||
> & {
|
|
||||||
estimateSize?: (index: number, row: Row<TData>) => number
|
|
||||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
|
||||||
getScrollElement?: (
|
|
||||||
elements: DataGridTableVirtualScrollElements
|
|
||||||
) => HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataGridTableVirtualProps<TData> {
|
|
||||||
height?: number | string
|
|
||||||
estimateSize?: number
|
|
||||||
overscan?: number
|
|
||||||
footerContent?: ReactNode
|
|
||||||
renderHeader?: boolean
|
|
||||||
onFetchMore?: () => void
|
|
||||||
isFetchingMore?: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
fetchMoreOffset?: number
|
|
||||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualBodyProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
columnCount: number
|
|
||||||
topRows: Row<TData>[]
|
|
||||||
centerRows: Row<TData>[]
|
|
||||||
bottomRows: Row<TData>[]
|
|
||||||
virtualItems: VirtualItem[]
|
|
||||||
totalSize: number
|
|
||||||
isVirtualizationEnabled: boolean
|
|
||||||
isInfiniteMode: boolean
|
|
||||||
isFetchingMore: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
loadingMoreMessage: ReactNode
|
|
||||||
allRowsLoadedMessage: ReactNode
|
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualSpacer({
|
|
||||||
columnCount,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
columnCount: number
|
|
||||||
height: number
|
|
||||||
}) {
|
|
||||||
if (height <= 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr aria-hidden="true">
|
|
||||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualStatusRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
columnCount,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
columnCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columnCount}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground py-4 text-center text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData>({
|
|
||||||
table,
|
|
||||||
columnCount,
|
|
||||||
topRows,
|
|
||||||
centerRows,
|
|
||||||
bottomRows,
|
|
||||||
virtualItems,
|
|
||||||
totalSize,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
isInfiniteMode,
|
|
||||||
isFetchingMore,
|
|
||||||
hasMore,
|
|
||||||
loadingMoreMessage,
|
|
||||||
allRowsLoadedMessage,
|
|
||||||
measureRowRef,
|
|
||||||
}: VirtualBodyProps<TData>) {
|
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
|
||||||
|
|
||||||
if (!totalRows) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
const hasCenterRows = centerRows.length > 0
|
|
||||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
|
||||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
|
||||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
|
||||||
const leadingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? (virtualItems[0]?.start ?? 0)
|
|
||||||
: 0
|
|
||||||
const trailingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const renderedRows: ReactNode[] = []
|
|
||||||
|
|
||||||
topRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isVirtualizationEnabled) {
|
|
||||||
if (leadingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-start"
|
|
||||||
columnCount={columnCount}
|
|
||||||
height={leadingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
virtualItems.forEach((virtualRow) => {
|
|
||||||
const row = centerRows[virtualRow.index]
|
|
||||||
|
|
||||||
if (!row) return
|
|
||||||
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
rowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (trailingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-end"
|
|
||||||
columnCount={columnCount}
|
|
||||||
height={trailingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
centerRows.forEach((row) => {
|
|
||||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showFetchingRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-loading"
|
|
||||||
columnCount={columnCount}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCompleteRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-complete"
|
|
||||||
columnCount={columnCount}
|
|
||||||
className="py-3 text-xs"
|
|
||||||
>
|
|
||||||
{allRowsLoadedMessage}
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
|
||||||
? "bottom"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{renderedRows}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized virtual body: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedVirtualBody = memo(
|
|
||||||
DataGridTableVirtualBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableVirtualBody
|
|
||||||
|
|
||||||
function DataGridTableVirtual<TData>({
|
|
||||||
height,
|
|
||||||
estimateSize = 48,
|
|
||||||
overscan = 10,
|
|
||||||
footerContent,
|
|
||||||
renderHeader = true,
|
|
||||||
onFetchMore,
|
|
||||||
isFetchingMore = false,
|
|
||||||
hasMore,
|
|
||||||
fetchMoreOffset = 0,
|
|
||||||
virtualizerOptions,
|
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
|
||||||
table,
|
|
||||||
props.tableLayout?.rowsPinnable
|
|
||||||
)
|
|
||||||
const columnCount =
|
|
||||||
table.getVisibleFlatColumns().length +
|
|
||||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
|
||||||
const isInfiniteMode = typeof onFetchMore === "function"
|
|
||||||
const [viewportElements, setViewportElements] =
|
|
||||||
useState<DataGridTableVirtualScrollElements>({
|
|
||||||
containerElement: null,
|
|
||||||
scrollElement: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
estimateSize: customEstimateSize,
|
|
||||||
getItemKey: customGetItemKey,
|
|
||||||
getScrollElement: customGetScrollElement,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
overscan: customOverscan,
|
|
||||||
...virtualizerOptionsRest
|
|
||||||
} = virtualizerOptions ?? {}
|
|
||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
|
||||||
const loadingMoreMessage =
|
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
|
||||||
const allRowsLoadedMessage =
|
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
setViewportElements({
|
|
||||||
containerElement: node,
|
|
||||||
scrollElement:
|
|
||||||
(node?.closest(
|
|
||||||
'[data-slot="scroll-area-viewport"]'
|
|
||||||
) as HTMLElement | null) ?? node,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const usesExternalScrollArea =
|
|
||||||
viewportElements.scrollElement !== null &&
|
|
||||||
viewportElements.scrollElement !== viewportElements.containerElement
|
|
||||||
|
|
||||||
const resolveScrollElement = useCallback(() => {
|
|
||||||
if (customGetScrollElement) {
|
|
||||||
return customGetScrollElement(viewportElements)
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewportElements.scrollElement
|
|
||||||
}, [customGetScrollElement, viewportElements])
|
|
||||||
|
|
||||||
const resolveItemKey = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
if (!row) return index
|
|
||||||
|
|
||||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
|
||||||
},
|
|
||||||
[centerRows, customGetItemKey]
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveEstimateSize = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
return row
|
|
||||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
|
||||||
: estimateSize
|
|
||||||
},
|
|
||||||
[centerRows, customEstimateSize, estimateSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: centerRows.length,
|
|
||||||
getScrollElement: resolveScrollElement,
|
|
||||||
getItemKey: resolveItemKey,
|
|
||||||
estimateSize: resolveEstimateSize,
|
|
||||||
overscan: customOverscan ?? overscan,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
...virtualizerOptionsRest,
|
|
||||||
}) as DataGridTableVirtualizerInstance
|
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
|
||||||
? virtualizer.getVirtualItems()
|
|
||||||
: []
|
|
||||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
|
||||||
const measureRowRef =
|
|
||||||
isVirtualizationEnabled && customMeasureElement
|
|
||||||
? virtualizer.measureElement
|
|
||||||
: undefined
|
|
||||||
const resolvedFetchMoreOffset = useMemo(
|
|
||||||
() => Math.max(0, fetchMoreOffset),
|
|
||||||
[fetchMoreOffset]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isVirtualizationEnabled ||
|
|
||||||
!isInfiniteMode ||
|
|
||||||
hasMore === false ||
|
|
||||||
isFetchingMore
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
|
||||||
if (!lastItem) return
|
|
||||||
|
|
||||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
|
||||||
onFetchMore?.()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
centerRows.length,
|
|
||||||
hasMore,
|
|
||||||
isFetchingMore,
|
|
||||||
isInfiniteMode,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
onFetchMore,
|
|
||||||
resolvedFetchMoreOffset,
|
|
||||||
virtualItems,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={handleViewportRef}
|
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
|
||||||
style={
|
|
||||||
usesExternalScrollArea
|
|
||||||
? undefined
|
|
||||||
: { height, overflow: "auto", position: "relative" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
{headerGroup.headers.map((header, hIndex) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
))}
|
|
||||||
</DataGridTableHead>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderHeader &&
|
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedVirtualBody
|
|
||||||
table={table}
|
|
||||||
columnCount={columnCount}
|
|
||||||
topRows={topRows}
|
|
||||||
centerRows={centerRows}
|
|
||||||
bottomRows={bottomRows}
|
|
||||||
virtualItems={virtualItems}
|
|
||||||
totalSize={totalSize}
|
|
||||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
|
||||||
isInfiniteMode={isInfiniteMode}
|
|
||||||
isFetchingMore={isFetchingMore}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadingMoreMessage={loadingMoreMessage}
|
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
|
||||||
measureRowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableVirtual }
|
|
||||||
export type {
|
|
||||||
DataGridTableVirtualProps,
|
|
||||||
DataGridTableVirtualScrollElements,
|
|
||||||
DataGridTableVirtualizerOptions,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,268 +0,0 @@
|
|||||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
|
||||||
import {
|
|
||||||
Column,
|
|
||||||
ColumnFiltersState,
|
|
||||||
RowData,
|
|
||||||
SortingState,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
|
|
||||||
declare module "@tanstack/react-table" {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
interface ColumnMeta<TData extends RowData, TValue> {
|
|
||||||
headerTitle?: string
|
|
||||||
headerClassName?: string
|
|
||||||
cellClassName?: string
|
|
||||||
skeleton?: ReactNode
|
|
||||||
expandedContent?: (row: TData) => ReactNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
|
||||||
export function getColumnHeaderLabel<TData, TValue>(
|
|
||||||
column: Column<TData, TValue>
|
|
||||||
): string {
|
|
||||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
|
||||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
|
||||||
const defHeader = column.columnDef.header
|
|
||||||
if (typeof defHeader === "string") return defHeader
|
|
||||||
return String(column.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridApiFetchParams = {
|
|
||||||
pageIndex: number
|
|
||||||
pageSize: number
|
|
||||||
sorting?: SortingState
|
|
||||||
filters?: ColumnFiltersState
|
|
||||||
searchQuery?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridApiResponse<T> = {
|
|
||||||
data: T[]
|
|
||||||
empty: boolean
|
|
||||||
pagination: {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataGridContextProps<TData extends object> {
|
|
||||||
props: DataGridProps<TData>
|
|
||||||
table: Table<TData>
|
|
||||||
recordCount: number
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridRequestParams = {
|
|
||||||
pageIndex: number
|
|
||||||
pageSize: number
|
|
||||||
sorting?: SortingState
|
|
||||||
columnFilters?: ColumnFiltersState
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataGridProps<TData extends object> {
|
|
||||||
className?: string
|
|
||||||
table?: Table<TData>
|
|
||||||
recordCount: number
|
|
||||||
children?: ReactNode
|
|
||||||
onRowClick?: (row: TData) => void
|
|
||||||
isLoading?: boolean
|
|
||||||
loadingMode?: "skeleton" | "spinner"
|
|
||||||
loadingMessage?: ReactNode | string
|
|
||||||
fetchingMoreMessage?: ReactNode | string
|
|
||||||
allRowsLoadedMessage?: ReactNode | string
|
|
||||||
emptyMessage?: ReactNode | string
|
|
||||||
tableLayout?: {
|
|
||||||
dense?: boolean
|
|
||||||
cellBorder?: boolean
|
|
||||||
rowBorder?: boolean
|
|
||||||
rowRounded?: boolean
|
|
||||||
stripped?: boolean
|
|
||||||
headerBackground?: boolean
|
|
||||||
headerBorder?: boolean
|
|
||||||
headerSticky?: boolean
|
|
||||||
width?: "auto" | "fixed"
|
|
||||||
columnsVisibility?: boolean
|
|
||||||
columnsResizable?: boolean
|
|
||||||
columnsResizeMode?: "onChange" | "onEnd"
|
|
||||||
columnsPinnable?: boolean
|
|
||||||
columnsMovable?: boolean
|
|
||||||
columnsDraggable?: boolean
|
|
||||||
rowsDraggable?: boolean
|
|
||||||
rowsPinnable?: boolean
|
|
||||||
}
|
|
||||||
tableClassNames?: {
|
|
||||||
base?: string
|
|
||||||
header?: string
|
|
||||||
headerRow?: string
|
|
||||||
headerSticky?: string
|
|
||||||
body?: string
|
|
||||||
bodyRow?: string
|
|
||||||
footer?: string
|
|
||||||
edgeCell?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataGridContext = createContext<
|
|
||||||
|
|
||||||
DataGridContextProps<any> | undefined
|
|
||||||
>(undefined)
|
|
||||||
|
|
||||||
function useDataGrid() {
|
|
||||||
const context = useContext(DataGridContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridProvider<TData extends object>({
|
|
||||||
children,
|
|
||||||
table,
|
|
||||||
...props
|
|
||||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
|
||||||
const tableState = table.getState()
|
|
||||||
const resolvedColumnsResizeMode =
|
|
||||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
|
||||||
|
|
||||||
// Keep resize mode aligned with the DataGrid contract every render so
|
|
||||||
// consumer-level useReactTable options cannot flip it back between drags.
|
|
||||||
if (props.tableLayout?.columnsResizable) {
|
|
||||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoize context value so consumers don't re-render during column resize.
|
|
||||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
|
||||||
// on the <table> element handle width updates without React re-renders.
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
props,
|
|
||||||
table,
|
|
||||||
recordCount: props.recordCount,
|
|
||||||
isLoading: props.isLoading || false,
|
|
||||||
}),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[
|
|
||||||
table,
|
|
||||||
props.recordCount,
|
|
||||||
props.isLoading,
|
|
||||||
props.loadingMode,
|
|
||||||
props.loadingMessage,
|
|
||||||
props.fetchingMoreMessage,
|
|
||||||
props.allRowsLoadedMessage,
|
|
||||||
props.emptyMessage,
|
|
||||||
props.onRowClick,
|
|
||||||
props.className,
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
JSON.stringify(props.tableLayout),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
JSON.stringify(props.tableClassNames),
|
|
||||||
tableState.sorting,
|
|
||||||
tableState.pagination,
|
|
||||||
tableState.columnFilters,
|
|
||||||
tableState.rowSelection,
|
|
||||||
tableState.expanded,
|
|
||||||
tableState.columnVisibility,
|
|
||||||
tableState.columnOrder,
|
|
||||||
tableState.columnPinning,
|
|
||||||
tableState.globalFilter,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</DataGridContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGrid<TData extends object>({
|
|
||||||
children,
|
|
||||||
table,
|
|
||||||
...props
|
|
||||||
}: DataGridProps<TData>) {
|
|
||||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
|
||||||
loadingMode: "skeleton",
|
|
||||||
tableLayout: {
|
|
||||||
dense: false,
|
|
||||||
cellBorder: false,
|
|
||||||
rowBorder: true,
|
|
||||||
rowRounded: false,
|
|
||||||
stripped: false,
|
|
||||||
headerSticky: false,
|
|
||||||
headerBackground: true,
|
|
||||||
headerBorder: true,
|
|
||||||
width: "fixed",
|
|
||||||
columnsVisibility: false,
|
|
||||||
columnsResizable: false,
|
|
||||||
columnsResizeMode: "onEnd",
|
|
||||||
columnsPinnable: false,
|
|
||||||
columnsMovable: false,
|
|
||||||
columnsDraggable: false,
|
|
||||||
rowsDraggable: false,
|
|
||||||
rowsPinnable: false,
|
|
||||||
},
|
|
||||||
tableClassNames: {
|
|
||||||
base: "",
|
|
||||||
header: "",
|
|
||||||
headerRow: "",
|
|
||||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
|
||||||
body: "",
|
|
||||||
bodyRow: "",
|
|
||||||
footer: "",
|
|
||||||
edgeCell: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedProps: DataGridProps<TData> = {
|
|
||||||
...defaultProps,
|
|
||||||
...props,
|
|
||||||
tableLayout: {
|
|
||||||
...defaultProps.tableLayout,
|
|
||||||
...(props.tableLayout || {}),
|
|
||||||
},
|
|
||||||
tableClassNames: {
|
|
||||||
...defaultProps.tableClassNames,
|
|
||||||
...(props.tableClassNames || {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure table is provided
|
|
||||||
if (!table) {
|
|
||||||
throw new Error('DataGrid requires a "table" prop')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridProvider table={table} {...mergedProps}>
|
|
||||||
{children}
|
|
||||||
</DataGridProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridContainer({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
border = true,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
border?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="data-grid"
|
|
||||||
className={cn(
|
|
||||||
"w-full overflow-hidden",
|
|
||||||
border &&
|
|
||||||
"border-border rounded-lg border",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
|||||||
import { createContext, ReactNode, useContext, useId } from "react"
|
|
||||||
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
|
||||||
import { cva, VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
|
||||||
import { Label } from "@evobgp/ui/components/label"
|
|
||||||
import { MinusIcon, PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const NumberFieldContext = createContext<{
|
|
||||||
fieldId: string
|
|
||||||
size: "sm" | "default" | "lg"
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const numberFieldGroupVariants = cva(
|
|
||||||
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: "h-7 text-sm",
|
|
||||||
default:
|
|
||||||
"h-8 text-sm",
|
|
||||||
lg: "h-9 text-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const numberFieldButtonVariants = cva(
|
|
||||||
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
||||||
default:
|
|
||||||
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const numberFieldInputVariants = cva(
|
|
||||||
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: "px-2 py-0.5",
|
|
||||||
default:
|
|
||||||
"px-2.5 py-1",
|
|
||||||
lg: "px-2.5 py-1.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function NumberField({
|
|
||||||
id,
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.Root.Props &
|
|
||||||
VariantProps<typeof numberFieldGroupVariants>) {
|
|
||||||
const generatedId = useId()
|
|
||||||
const fieldId = id ?? generatedId
|
|
||||||
const sizeValue = size ?? "default"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
|
||||||
<NumberFieldPrimitive.Root
|
|
||||||
className={cn("flex w-full flex-col items-start gap-2", className)}
|
|
||||||
data-size={sizeValue}
|
|
||||||
data-slot="number-field"
|
|
||||||
id={fieldId}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</NumberFieldContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberFieldGroup({
|
|
||||||
className,
|
|
||||||
size: sizeProp,
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.Group.Props &
|
|
||||||
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
|
||||||
const context = useContext(NumberFieldContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"NumberFieldGroup must be used within a NumberField component."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const size = sizeProp ?? context.size
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldPrimitive.Group
|
|
||||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
|
||||||
data-slot="number-field-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberFieldDecrement({
|
|
||||||
className,
|
|
||||||
size: sizeProp,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.Decrement.Props &
|
|
||||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const context = useContext(NumberFieldContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"NumberFieldDecrement must be used within a NumberField component."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const size = sizeProp ?? context.size
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldPrimitive.Decrement
|
|
||||||
className={cn(
|
|
||||||
numberFieldButtonVariants({ size }),
|
|
||||||
"rounded-s-lg border-e-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-slot="number-field-decrement"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<MinusIcon
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</NumberFieldPrimitive.Decrement>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberFieldIncrement({
|
|
||||||
className,
|
|
||||||
size: sizeProp,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.Increment.Props &
|
|
||||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
|
||||||
children?: ReactNode
|
|
||||||
}) {
|
|
||||||
const context = useContext(NumberFieldContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"NumberFieldIncrement must be used within a NumberField component."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const size = sizeProp ?? context.size
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldPrimitive.Increment
|
|
||||||
className={cn(
|
|
||||||
numberFieldButtonVariants({ size }),
|
|
||||||
"rounded-e-lg border-s-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-slot="number-field-increment"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<PlusIcon
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</NumberFieldPrimitive.Increment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberFieldInput({
|
|
||||||
className,
|
|
||||||
size: sizeProp,
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.Input.Props &
|
|
||||||
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
|
||||||
const context = useContext(NumberFieldContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"NumberFieldInput must be used within a NumberField component."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const size = sizeProp ?? context.size
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldPrimitive.Input
|
|
||||||
className={cn(numberFieldInputVariants({ size }), className)}
|
|
||||||
data-slot="number-field-input"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberFieldScrubArea({
|
|
||||||
className,
|
|
||||||
label,
|
|
||||||
...props
|
|
||||||
}: NumberFieldPrimitive.ScrubArea.Props & {
|
|
||||||
label: string
|
|
||||||
}) {
|
|
||||||
const context = useContext(NumberFieldContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberFieldPrimitive.ScrubArea
|
|
||||||
className={cn("flex cursor-ew-resize", className)}
|
|
||||||
data-slot="number-field-scrub-area"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
|
||||||
<CursorGrowIcon />
|
|
||||||
</NumberFieldPrimitive.ScrubAreaCursor>
|
|
||||||
</NumberFieldPrimitive.ScrubArea>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
fill="black"
|
|
||||||
height="14"
|
|
||||||
stroke="white"
|
|
||||||
viewBox="0 0 24 14"
|
|
||||||
width="26"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
NumberField,
|
|
||||||
NumberFieldScrubArea,
|
|
||||||
NumberFieldDecrement,
|
|
||||||
NumberFieldIncrement,
|
|
||||||
NumberFieldGroup,
|
|
||||||
NumberFieldInput,
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react'
|
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
|
||||||
|
|
||||||
export interface SectionCardItem {
|
|
||||||
label: ReactNode
|
|
||||||
value: string | number | ReactElement
|
|
||||||
hint?: ReactNode
|
|
||||||
icon?: ReactNode
|
|
||||||
badge?: ReactNode
|
|
||||||
variant?: 'default' | 'warning' | 'destructive'
|
|
||||||
active?: boolean
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
|
||||||
default: '',
|
|
||||||
warning: 'border-warning/50',
|
|
||||||
destructive: 'border-destructive/50',
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionGridClass(count: number): string {
|
|
||||||
if (count <= 1) return 'grid-cols-1'
|
|
||||||
if (count === 2) return 'sm:grid-cols-2'
|
|
||||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
|
||||||
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
|
||||||
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
|
||||||
return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
|
||||||
default: '',
|
|
||||||
warning: 'text-warning-foreground',
|
|
||||||
destructive: 'text-destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
|
||||||
{items.map((item, idx) => {
|
|
||||||
const clickable = Boolean(item.onClick)
|
|
||||||
const content = (
|
|
||||||
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
|
||||||
{item.icon ? (
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
{typeof item.label === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
|
||||||
)}
|
|
||||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
|
||||||
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
{item.hint ? (
|
|
||||||
typeof item.hint === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={typeof item.label === 'string' ? item.label : idx}
|
|
||||||
className={cn(
|
|
||||||
'gap-0',
|
|
||||||
VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
item.active && 'border-primary ring-1 ring-primary/30',
|
|
||||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
|
||||||
)}
|
|
||||||
onClick={item.onClick}
|
|
||||||
role={clickable ? 'button' : undefined}
|
|
||||||
tabIndex={clickable ? 0 : undefined}
|
|
||||||
onKeyDown={
|
|
||||||
clickable
|
|
||||||
? (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
item.onClick?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { SectionCards } from './section-cards'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
|
|
||||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<SectionCards
|
|
||||||
items={Array.from({ length: count }, (_, i) => ({
|
|
||||||
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
|
||||||
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
|
||||||
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
|
||||||
return (
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex gap-2 border-b p-3">
|
|
||||||
{Array.from({ length: cols }).map((_, i) => (
|
|
||||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: rows }).map((_, r) => (
|
|
||||||
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
|
||||||
{Array.from({ length: cols }).map((_, c) => (
|
|
||||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|
||||||
active: 'success',
|
|
||||||
ok: 'success',
|
|
||||||
paid: 'success',
|
|
||||||
established: 'success',
|
|
||||||
succeeded: 'success',
|
|
||||||
healthy: 'success',
|
|
||||||
paused: 'secondary',
|
|
||||||
disabled: 'secondary',
|
|
||||||
archived: 'outline',
|
|
||||||
error: 'destructive',
|
|
||||||
failed: 'destructive',
|
|
||||||
running: 'info',
|
|
||||||
queued: 'info',
|
|
||||||
overdue: 'warning',
|
|
||||||
stale: 'warning',
|
|
||||||
warning: 'warning',
|
|
||||||
mismatch: 'warning',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
|
||||||
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
|
||||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<NextThemesProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
storageKey="evobgp-theme"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@evobgp/ui/components/tooltip'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
interface TruncatedTextProps {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
as?: 'span' | 'p' | 'div'
|
|
||||||
/** Явный текст подсказки, если children — не строка. */
|
|
||||||
tooltip?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
|
|
||||||
const tip =
|
|
||||||
tooltip ??
|
|
||||||
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
|
|
||||||
|
|
||||||
if (!tip) {
|
|
||||||
return <Tag className={cn('truncate', className)}>{children}</Tag>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
|
|
||||||
<TooltipContent>{tip}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import type {
|
|
||||||
AsEntriesResponse,
|
|
||||||
CdnSourcesResponse,
|
|
||||||
DomainEntriesResponse,
|
|
||||||
IpRangeEntriesResponse,
|
|
||||||
JobRow,
|
|
||||||
ModuleType,
|
|
||||||
RevisionPrefix,
|
|
||||||
RevisionPrefixesResponse,
|
|
||||||
} from '@/types/api'
|
|
||||||
|
|
||||||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
|
||||||
|
|
||||||
export type Problem = {
|
|
||||||
type?: string
|
|
||||||
title?: string
|
|
||||||
status?: number
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function getToken(): string | null {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToken(token: string | null): void {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
|
||||||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
|
|
||||||
const h = new Headers(init?.headers)
|
|
||||||
if (!h.has('Accept')) h.set('Accept', 'application/json')
|
|
||||||
const t = getToken()
|
|
||||||
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
|
|
||||||
if (extraHeaders) {
|
|
||||||
for (const [k, v] of Object.entries(extraHeaders)) {
|
|
||||||
if (!h.has(k)) h.set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
|
|
||||||
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
|
|
||||||
*/
|
|
||||||
function newIdempotencyKey(): string {
|
|
||||||
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
|
|
||||||
if (c?.randomUUID) return c.randomUUID()
|
|
||||||
if (c?.getRandomValues) {
|
|
||||||
const buf = new Uint8Array(16)
|
|
||||||
c.getRandomValues(buf)
|
|
||||||
buf[6] = (buf[6]! & 0x0f) | 0x40
|
|
||||||
buf[8] = (buf[8]! & 0x3f) | 0x80
|
|
||||||
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
||||||
}
|
|
||||||
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
message: string,
|
|
||||||
public readonly problem?: Problem,
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
||||||
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
|
|
||||||
return fetch(path, { ...init, headers: mergeHeaders(init) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET / DELETE без тела */
|
|
||||||
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const res = await apiFetch(path, init)
|
|
||||||
return parseResponse<T>(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
|
|
||||||
export async function apiMutate<T = void>(
|
|
||||||
path: string,
|
|
||||||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
||||||
body?: unknown,
|
|
||||||
opts?: { idempotent?: boolean },
|
|
||||||
): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
||||||
if (opts?.idempotent !== false) {
|
|
||||||
headers['Idempotency-Key'] = newIdempotencyKey()
|
|
||||||
}
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method,
|
|
||||||
headers: mergeHeaders({ headers }, headers),
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
return parseResponse<T>(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseResponse<T>(res: Response): Promise<T> {
|
|
||||||
if (res.status === 204 || res.status === 205) return undefined as T
|
|
||||||
const text = await res.text()
|
|
||||||
if (!res.ok) {
|
|
||||||
let problem: Problem | undefined
|
|
||||||
let detail = `HTTP ${res.status}`
|
|
||||||
try {
|
|
||||||
problem = JSON.parse(text) as Problem
|
|
||||||
detail = problem.detail ?? problem.title ?? detail
|
|
||||||
} catch {
|
|
||||||
if (text) detail = text
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, detail, problem)
|
|
||||||
}
|
|
||||||
if (!text) return undefined as T
|
|
||||||
return JSON.parse(text) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
|
|
||||||
|
|
||||||
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
|
|
||||||
export async function waitForJob(
|
|
||||||
jobId: string,
|
|
||||||
opts?: { pollMs?: number; timeoutMs?: number },
|
|
||||||
): Promise<JobRow> {
|
|
||||||
const pollMs = opts?.pollMs ?? 400
|
|
||||||
const timeoutMs = opts?.timeoutMs ?? 120000
|
|
||||||
const deadline = Date.now() + timeoutMs
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
|
|
||||||
if (terminalJobStatuses.has(j.status)) return j
|
|
||||||
await new Promise((r) => setTimeout(r, pollMs))
|
|
||||||
}
|
|
||||||
throw new Error(`Таймаут ожидания задачи ${jobId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
|
|
||||||
const items: T[] = []
|
|
||||||
let cursor: string | null = null
|
|
||||||
while (true) {
|
|
||||||
const [basePath, rawQuery = ''] = path.split('?')
|
|
||||||
const query = new URLSearchParams(rawQuery)
|
|
||||||
if (!query.has('limit')) query.set('limit', String(limit))
|
|
||||||
if (cursor) query.set('cursor', cursor)
|
|
||||||
else query.delete('cursor')
|
|
||||||
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
|
|
||||||
`${basePath}?${query.toString()}`,
|
|
||||||
)
|
|
||||||
items.push(...(page.items ?? []))
|
|
||||||
if (!page.has_more || !page.next_cursor) break
|
|
||||||
cursor = page.next_cursor
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
|
|
||||||
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRevisionPrefixesResponse(
|
|
||||||
revisionId: string,
|
|
||||||
cursor?: string | null,
|
|
||||||
limit = 500,
|
|
||||||
): Promise<RevisionPrefixesResponse> {
|
|
||||||
const query = new URLSearchParams({ limit: String(limit) })
|
|
||||||
if (cursor) query.set('cursor', cursor)
|
|
||||||
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
|
|
||||||
if (moduleType === 'DOMAINS') {
|
|
||||||
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
|
|
||||||
`/v1/modules/${moduleId}/domain-entries`,
|
|
||||||
)
|
|
||||||
return { domains: entries }
|
|
||||||
}
|
|
||||||
if (moduleType === 'AS_PREFIXES') {
|
|
||||||
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
|
|
||||||
`/v1/modules/${moduleId}/as-entries`,
|
|
||||||
)
|
|
||||||
return { asns: entries }
|
|
||||||
}
|
|
||||||
if (moduleType === 'CDN_CIDRS') {
|
|
||||||
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
|
|
||||||
`/v1/modules/${moduleId}/cdn-sources`,
|
|
||||||
)
|
|
||||||
return { cdnSources: entries }
|
|
||||||
}
|
|
||||||
if (moduleType === 'IP_RANGES') {
|
|
||||||
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
|
|
||||||
`/v1/modules/${moduleId}/ip-range-entries`,
|
|
||||||
)
|
|
||||||
return { ipRanges: entries }
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from '@evobgp/shared/types/api.js';
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
+13
-8
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
|
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -16,12 +16,17 @@
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
Select,
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
SelectContent,
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
SelectItem,
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import PageShell from '$lib/components/page-shell.svelte';
|
||||||
|
import QueryState from '$lib/components/query-state.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: Snippet;
|
||||||
|
data: unknown;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
onRetry?: () => void;
|
||||||
|
skeleton?: Snippet;
|
||||||
|
content: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
skeleton,
|
||||||
|
content
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader {title} {description}>
|
||||||
|
{#snippet actions()}
|
||||||
|
{#if actions}
|
||||||
|
{@render actions()}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
{#if isLoading}
|
||||||
|
{#if skeleton}
|
||||||
|
{@render skeleton()}
|
||||||
|
{/if}
|
||||||
|
{:else if isError}
|
||||||
|
<QueryState {data} isLoading={false} isError={true} {error} {onRetry} children={emptyChild} />
|
||||||
|
{:else}
|
||||||
|
{@render content()}
|
||||||
|
{/if}
|
||||||
|
</PageShell>
|
||||||
|
|
||||||
|
{#snippet emptyChild()}{/snippet}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
toolbar?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { title, description, toolbar, class: className, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class={cn('gap-0 py-0', className)}>
|
||||||
|
{#if title || description || toolbar}
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-row flex-wrap items-start justify-between gap-2 border-b px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
{#if title}
|
||||||
|
<CardTitle class="text-base">{title}</CardTitle>
|
||||||
|
{/if}
|
||||||
|
{#if description}
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if toolbar}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">{@render toolbar()}</div>
|
||||||
|
{/if}
|
||||||
|
</CardHeader>
|
||||||
|
{/if}
|
||||||
|
<CardContent class="p-0">{@render children()}</CardContent>
|
||||||
|
</Card>
|
||||||
+7
-7
@@ -1,25 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
|
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
+7
-7
@@ -1,25 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
|
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import * as Sheet from '@evobgp/ui/components/sheet/index.js';
|
||||||
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
submitting?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
submitLabel = 'Сохранить',
|
||||||
|
submitting = false,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sheet.Root {open} {onOpenChange}>
|
||||||
|
<Sheet.Content class="flex w-full flex-col gap-0 sm:max-w-lg">
|
||||||
|
<Sheet.Header>
|
||||||
|
<Sheet.Title>{title}</Sheet.Title>
|
||||||
|
{#if description}
|
||||||
|
<Sheet.Description>{description}</Sheet.Description>
|
||||||
|
{/if}
|
||||||
|
</Sheet.Header>
|
||||||
|
<div class="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{#if onSubmit}
|
||||||
|
<Sheet.Footer>
|
||||||
|
<Button onclick={onSubmit} disabled={submitting}>{submitLabel}</Button>
|
||||||
|
</Sheet.Footer>
|
||||||
|
{/if}
|
||||||
|
</Sheet.Content>
|
||||||
|
</Sheet.Root>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import * as Sidebar from '@evobgp/ui/components/sidebar/index.js';
|
||||||
|
import * as Breadcrumb from '@evobgp/ui/components/breadcrumb/index.js';
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator/index.js';
|
||||||
|
import type { ThemePreference } from '$lib/theme.js';
|
||||||
|
import { mainNav, bottomNav } from '$lib/ui/app/layout/nav.js';
|
||||||
|
import ThemeMenu from '$lib/ui/app/layout/theme-menu.svelte';
|
||||||
|
import AppVersion from '$lib/ui/app/layout/app-version.svelte';
|
||||||
|
import AppMobileNav from '$lib/ui/app/layout/app-mobile-nav.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: Snippet;
|
||||||
|
theme?: ThemePreference;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { children, theme = $bindable<ThemePreference>('system') }: Props = $props();
|
||||||
|
|
||||||
|
let mobileNavOpen = $state(false);
|
||||||
|
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
'/': 'Обзор',
|
||||||
|
...Object.fromEntries(mainNav.map((i) => [i.href, i.label])),
|
||||||
|
...Object.fromEntries(bottomNav.map((i) => [i.href, i.label]))
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbLabel = $derived(routeLabels[page.url.pathname] ?? 'EvoBGP');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const navHref = (href: string) => resolve(href as any);
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
const pathname = page.url.pathname;
|
||||||
|
if (href === '/') return pathname === '/';
|
||||||
|
return pathname === href || pathname.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Provider>
|
||||||
|
<Sidebar.Root>
|
||||||
|
<Sidebar.Header class="border-b border-sidebar-border">
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
|
||||||
|
<a href={navHref('/')} class="truncate font-semibold tracking-tight">EvoBGP</a>
|
||||||
|
<p class="truncate text-xs text-muted-foreground">Панель управления</p>
|
||||||
|
</div>
|
||||||
|
<ThemeMenu bind:theme />
|
||||||
|
</div>
|
||||||
|
</Sidebar.Header>
|
||||||
|
<Sidebar.Content>
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.GroupLabel>Операции</Sidebar.GroupLabel>
|
||||||
|
<Sidebar.GroupContent>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each mainNav as item (item.href)}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={isActive(item.href)}>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={navHref(item.href)} {...props}>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.GroupContent>
|
||||||
|
</Sidebar.Group>
|
||||||
|
</Sidebar.Content>
|
||||||
|
<Sidebar.Footer class="border-t border-sidebar-border">
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each bottomNav as item (item.href)}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={isActive(item.href)}>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={navHref(item.href)} {...props}>
|
||||||
|
<Icon class="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
<AppVersion />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
<Sidebar.Rail />
|
||||||
|
</Sidebar.Root>
|
||||||
|
<Sidebar.Inset>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 md:hidden">
|
||||||
|
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
|
||||||
|
<span class="font-semibold tracking-tight">EvoBGP</span>
|
||||||
|
</div>
|
||||||
|
<Sidebar.Trigger class="-ms-1 hidden md:flex" />
|
||||||
|
<Separator orientation="vertical" class="mx-2 hidden h-4 md:block" />
|
||||||
|
<Breadcrumb.Root class="hidden min-w-0 md:flex">
|
||||||
|
<Breadcrumb.List>
|
||||||
|
<Breadcrumb.Item>
|
||||||
|
<Breadcrumb.Page>{breadcrumbLabel}</Breadcrumb.Page>
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
</Breadcrumb.List>
|
||||||
|
</Breadcrumb.Root>
|
||||||
|
</header>
|
||||||
|
<main class="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</Sidebar.Inset>
|
||||||
|
</Sidebar.Provider>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
export type FilterChip = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
chips?: FilterChip[];
|
||||||
|
onRemoveChip?: (id: string) => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { chips = [], onRemoveChip, onClear, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
{#each chips as chip (chip.id)}
|
||||||
|
<Badge variant="secondary" class="gap-1 pr-1">
|
||||||
|
{chip.label}
|
||||||
|
{#if onRemoveChip}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-5"
|
||||||
|
onclick={() => onRemoveChip(chip.id)}
|
||||||
|
aria-label="Убрать фильтр {chip.label}"
|
||||||
|
>
|
||||||
|
<X class="size-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
{#if chips.length > 0 && onClear}
|
||||||
|
<Button variant="ghost" size="sm" onclick={onClear}>Сбросить</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
+5
-5
@@ -8,17 +8,17 @@
|
|||||||
supportsCsvIO
|
supportsCsvIO
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
|
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
+10
-5
@@ -8,9 +8,9 @@
|
|||||||
NONE_OPTION,
|
NONE_OPTION,
|
||||||
nullableSelectValue
|
nullableSelectValue
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -18,8 +18,13 @@
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+10
-5
@@ -14,17 +14,22 @@
|
|||||||
NONE_OPTION,
|
NONE_OPTION,
|
||||||
nullableSelectValue
|
nullableSelectValue
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+6
-6
@@ -6,18 +6,18 @@
|
|||||||
communityLabel,
|
communityLabel,
|
||||||
normalizeCdnSourceKind
|
normalizeCdnSourceKind
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
+11
-6
@@ -2,9 +2,9 @@
|
|||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { ModuleCreate } from '$lib/api/types.js';
|
import type { ModuleCreate } from '$lib/api/types.js';
|
||||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -12,9 +12,14 @@
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ModuleRow } from '$lib/api/types.js';
|
import type { ModuleRow } from '$lib/api/types.js';
|
||||||
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
+5
-5
@@ -6,17 +6,17 @@
|
|||||||
sanitizeFilenamePart,
|
sanitizeFilenamePart,
|
||||||
supportsCsvIO
|
supportsCsvIO
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
+10
-5
@@ -8,17 +8,22 @@
|
|||||||
NONE_OPTION,
|
NONE_OPTION,
|
||||||
nullableSelectValue
|
nullableSelectValue
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+12
-7
@@ -16,19 +16,24 @@
|
|||||||
NONE_OPTION,
|
NONE_OPTION,
|
||||||
nullableSelectValue
|
nullableSelectValue
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
||||||
+10
-5
@@ -2,17 +2,22 @@
|
|||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
|
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
|
||||||
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
|
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '@evobgp/ui/components/input/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger
|
||||||
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+5
-5
@@ -6,17 +6,17 @@
|
|||||||
sanitizeFilenamePart,
|
sanitizeFilenamePart,
|
||||||
supportsCsvIO
|
supportsCsvIO
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
|
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
dohProfileLabel,
|
dohProfileLabel,
|
||||||
moduleDohProfileIds
|
moduleDohProfileIds
|
||||||
} from '$lib/components/modules/module-helpers.js';
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card/index.js';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
|
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
|
||||||
import Timer from '@lucide/svelte/icons/timer';
|
import Timer from '@lucide/svelte/icons/timer';
|
||||||
+2
-2
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mod: ModuleRow;
|
mod: ModuleRow;
|
||||||
+13
-13
@@ -35,31 +35,31 @@
|
|||||||
type ScheduleEditor,
|
type ScheduleEditor,
|
||||||
type ScheduleMode
|
type ScheduleMode
|
||||||
} from '$lib/maintenance/policy-schedule.js';
|
} from '$lib/maintenance/policy-schedule.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
|
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
+8
-8
@@ -17,17 +17,17 @@
|
|||||||
type PostgresMaintLog,
|
type PostgresMaintLog,
|
||||||
type CorrelationResponse
|
type CorrelationResponse
|
||||||
} from '$lib/monitoring/postgres.js';
|
} from '$lib/monitoring/postgres.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/ui/core/table/index.js';
|
} from '@evobgp/ui/components/table/index.js';
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import Database from '@lucide/svelte/icons/database';
|
import Database from '@lucide/svelte/icons/database';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
+10
-10
@@ -14,33 +14,33 @@
|
|||||||
} from '$lib/runtime-logs/runtime-logs-api.js';
|
} from '$lib/runtime-logs/runtime-logs-api.js';
|
||||||
import { formatBytes } from '$lib/monitoring/postgres.js';
|
import { formatBytes } from '$lib/monitoring/postgres.js';
|
||||||
import { formatDateTime } from '$lib/modules/display.js';
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '$lib/ui/core/dropdown-menu/index.js';
|
} from '@evobgp/ui/components/dropdown-menu/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
|
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
|
||||||
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import {
|
import {
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+2
-2
@@ -3,14 +3,14 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { loadSettings, partitionSettings } from '$lib/settings/settings-api.js';
|
import { loadSettings, partitionSettings } from '$lib/settings/settings-api.js';
|
||||||
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
+3
-3
@@ -8,9 +8,9 @@
|
|||||||
networkOverallStatusHint,
|
networkOverallStatusHint,
|
||||||
networkOverallStatusLabel
|
networkOverallStatusLabel
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
+16
-11
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { apiMutate } from '$lib/api/client.js';
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
|
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Label } from '$lib/ui/core/label/index.js';
|
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -18,13 +18,18 @@
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
import {
|
||||||
import { Switch } from '$lib/ui/core/switch/index.js';
|
Select,
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
SelectContent,
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
SelectItem,
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
SelectTrigger
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
} from '@evobgp/ui/components/select/index.js';
|
||||||
|
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||||
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
+6
-6
@@ -9,17 +9,17 @@
|
|||||||
speakerLiveAgentError,
|
speakerLiveAgentError,
|
||||||
speakerLiveBgpError
|
speakerLiveBgpError
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import { Separator } from '$lib/ui/core/separator/index.js';
|
import { Separator } from '@evobgp/ui/components/separator/index.js';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle
|
SheetTitle
|
||||||
} from '$lib/ui/core/sheet/index.js';
|
} from '@evobgp/ui/components/sheet/index.js';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/ui/core/table/index.js';
|
} from '@evobgp/ui/components/table/index.js';
|
||||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
+2
-2
@@ -6,14 +6,14 @@
|
|||||||
speakerHasDrift,
|
speakerHasDrift,
|
||||||
speakerLabel
|
speakerLabel
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import Server from '@lucide/svelte/icons/server';
|
import Server from '@lucide/svelte/icons/server';
|
||||||
|
|
||||||
+9
-9
@@ -6,15 +6,15 @@
|
|||||||
speakerDisplayStatus,
|
speakerDisplayStatus,
|
||||||
speakerHasDrift
|
speakerHasDrift
|
||||||
} from '$lib/network/network-metrics.js';
|
} from '$lib/network/network-metrics.js';
|
||||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/ui/core/card/index.js';
|
} from '@evobgp/ui/components/card/index.js';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -22,12 +22,12 @@
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/ui/core/dialog/index.js';
|
} from '@evobgp/ui/components/dialog/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user