Выравнивание UI с vps-tracker: тема b2fA, ReUI DataGrid и shell
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: reui
|
||||||
|
description: ReUI enterprise-компоненты в cloudflare-domain-manager — registry @reui, CLI, импорты, матрица выбора. Использовать при Data Grid, Filters и других @reui/* задачах.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI (cloudflare-domain-manager)
|
||||||
|
|
||||||
|
## Источники
|
||||||
|
|
||||||
|
1. [llms.txt](https://reui.io/llms.txt) — каталог для ИИ
|
||||||
|
2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]`
|
||||||
|
3. Docs: `https://reui.io/docs/components/base/<name>` (Copy Markdown)
|
||||||
|
4. Правила: `.cursor/rules/reui-mcp.mdc`
|
||||||
|
|
||||||
|
## CLI (только из apps/web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn@latest add @reui/data-grid --dry-run
|
||||||
|
pnpm dlx shadcn@latest add @reui/filters
|
||||||
|
```
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
| Registry | Путь | Импорт |
|
||||||
|
|----------|------|--------|
|
||||||
|
| `@shadcn` | `packages/ui/src/components/` | `@cfdm/ui/components/*` |
|
||||||
|
| `@reui` | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||||
|
|
||||||
|
Только **Base UI** (`base-nova`). Radix-варианты не использовать.
|
||||||
|
|
||||||
|
## Уже установлено
|
||||||
|
|
||||||
|
`badge`, `data-grid/*`, `filters` (+ `autocomplete`, `date-selector`, `number-field`, `color-picker` при необходимости)
|
||||||
|
|
||||||
|
## Shared-обёртки проекта
|
||||||
|
|
||||||
|
| ReUI | Обёртка |
|
||||||
|
|------|---------|
|
||||||
|
| data-grid | `DataGridCard` |
|
||||||
|
| filters | `DomainsFiltersToolbar` |
|
||||||
|
| badge | `StatusBadge` |
|
||||||
|
|
||||||
|
## Матрица выбора
|
||||||
|
|
||||||
|
- Простая таблица → shadcn `Table`
|
||||||
|
- Списки с sort/pagination/virtual/columns → `DataGridCard` (`@reui/data-grid`)
|
||||||
|
- Мультифильтры → `@reui/filters`
|
||||||
|
- Semantic status → `StatusBadge` или `@reui/badge` variant
|
||||||
|
|
||||||
|
## Зависимости (apps/web only)
|
||||||
|
|
||||||
|
`@tanstack/react-table`, `@tanstack/react-virtual`, `@dnd-kit/*`, `date-fns`, `react-day-picker`
|
||||||
|
|
||||||
|
После add — `pnpm install` + `pnpm --filter web build`.
|
||||||
|
|
||||||
|
## Post-add
|
||||||
|
|
||||||
|
- `@/components/ui/*` → `@cfdm/ui/components/*`
|
||||||
|
- Не класть ReUI в `packages/ui`
|
||||||
|
- Semantic colors: `variant="success"` — не `bg-emerald-*`
|
||||||
@@ -47,15 +47,14 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl
|
|||||||
| `EmptyState` | `empty-state.tsx` |
|
| `EmptyState` | `empty-state.tsx` |
|
||||||
| `QueryState` | `query-state.tsx` |
|
| `QueryState` | `query-state.tsx` |
|
||||||
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
||||||
| `DataTableCard` | `data-table-card.tsx` |
|
| `DataGridCard` | `data-grid-card.tsx` |
|
||||||
| `SectionCards` | `section-cards.tsx` |
|
| `SectionCards` | `section-cards.tsx` |
|
||||||
| `StatusBadge` | `status-badge.tsx` |
|
| `StatusBadge` | `status-badge.tsx` |
|
||||||
| `FormSheet` | `form-sheet.tsx` |
|
| `FormSheet` | `form-sheet.tsx` |
|
||||||
| `FormField` | `form-field.tsx` |
|
| `FormField` | `form-field.tsx` |
|
||||||
| `TableCard` | `table-card.tsx` |
|
| `TableCard` | `table-card.tsx` |
|
||||||
| `LoadingButton` | `loading-button.tsx` |
|
| `LoadingButton` | `loading-button.tsx` |
|
||||||
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
|
| `SectionCardsSkeleton` / `TableSkeleton` | `skeletons.tsx` |
|
||||||
| `TableSkeleton` | `table-skeleton.tsx` |
|
|
||||||
|
|
||||||
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
||||||
|
|
||||||
|
|||||||
@@ -29,15 +29,14 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
empty-state.tsx
|
empty-state.tsx
|
||||||
query-state.tsx
|
query-state.tsx
|
||||||
confirm-dialog.tsx
|
confirm-dialog.tsx
|
||||||
data-table-card.tsx
|
data-grid-card.tsx
|
||||||
section-cards.tsx
|
section-cards.tsx
|
||||||
status-badge.tsx
|
status-badge.tsx
|
||||||
form-sheet.tsx ← Sheet + RHF FormProvider
|
form-sheet.tsx ← Sheet + RHF FormProvider
|
||||||
form-field.tsx ← Field + Controller + aria-invalid
|
form-field.tsx ← Field + Controller + aria-invalid
|
||||||
table-card.tsx ← Card + Table wrapper
|
table-card.tsx ← Card + Table wrapper
|
||||||
loading-button.tsx ← Button + Spinner + label swap
|
loading-button.tsx ← Button + Spinner + label swap
|
||||||
section-cards-skeleton.tsx
|
skeletons.tsx ← SectionCardsSkeleton, TableSkeleton
|
||||||
table-skeleton.tsx
|
|
||||||
layout/ ← app-shell, site-header
|
layout/ ← app-shell, site-header
|
||||||
domain-* ← бизнес-компоненты
|
domain-* ← бизнес-компоненты
|
||||||
```
|
```
|
||||||
@@ -49,7 +48,7 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
| Page wrapper | `PageShell` | — |
|
| Page wrapper | `PageShell` | — |
|
||||||
| Page title | `PageHeader` | — |
|
| Page title | `PageHeader` | — |
|
||||||
| Stat metrics | `SectionCards` | `Card` |
|
| Stat metrics | `SectionCards` | `Card` |
|
||||||
| Data list | `DataTableCard` | `Table`, `InputGroup` |
|
| Data list | `DataGridCard` | ReUI `data-grid` |
|
||||||
| Empty | `EmptyState` | `Empty` |
|
| Empty | `EmptyState` | `Empty` |
|
||||||
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
|
||||||
| Status | `StatusBadge` | `Badge` |
|
| Status | `StatusBadge` | `Badge` |
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
description: ReUI registry (@reui) — enterprise-компоненты, MCP workflow, зависимости, матрица выбора shadcn vs ReUI
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI MCP — обязательно для enterprise-компонентов
|
||||||
|
|
||||||
|
ReUI — first-class shadcn registry с enterprise-компонентами (Data Grid, Filters, Stepper и др.). Проект использует **Base UI** (`style: base-nova`), не Radix.
|
||||||
|
|
||||||
|
Связанные правила: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
|
||||||
|
|
||||||
|
| Документ | URL |
|
||||||
|
|----------|-----|
|
||||||
|
| **llms.txt (карта для ИИ)** | https://reui.io/llms.txt |
|
||||||
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
|
| **Styling (semantic tokens)** | https://reui.io/docs/styling |
|
||||||
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||||
|
|
||||||
|
## Когда использовать ReUI vs shadcn
|
||||||
|
|
||||||
|
| Задача | Registry | Импорт |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Button, Card, Sheet, Field, Sidebar | `@shadcn` | `@cfdm/ui/components/*` |
|
||||||
|
| Blocks (sidebar-07, dashboard-01) | `@shadcn` | blocks → `apps/web/src/components/` |
|
||||||
|
| Data Grid (sort, pagination, virtual, columns) | `@reui` | `@/components/reui/data-grid/*` → обёртка `DataGridCard` |
|
||||||
|
| Мультифильтры | `@reui` | `@/components/reui/filters` → `DomainsFiltersToolbar` |
|
||||||
|
| Number field со stepper | `@reui` | `@/components/reui/number-field` |
|
||||||
|
| Autocomplete | `@reui` | `@/components/reui/autocomplete` → `AutoCompleteInput` |
|
||||||
|
| Date selector / range | `@reui` | `@/components/reui/date-selector` |
|
||||||
|
| Color picker (формы) | `@reui` | `@/components/reui/color-picker` — hex только в swatch data |
|
||||||
|
| Semantic badge (success/info/warning) | `@reui` | `@/components/reui/badge` или `StatusBadge` |
|
||||||
|
|
||||||
|
**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table.
|
||||||
|
|
||||||
|
## Источники для ИИ (порядок)
|
||||||
|
|
||||||
|
1. [ReUI llms.txt](https://reui.io/llms.txt) — полный каталог компонентов
|
||||||
|
2. MCP `plugin-shadcn-shadcn` с `registries: ["@reui"]`
|
||||||
|
3. Docs с **Copy Markdown**: `https://reui.io/docs/components/base/<name>`
|
||||||
|
4. CLI dry-run: `cd apps/web && pnpm dlx shadcn@latest add @reui/<name> --dry-run`
|
||||||
|
|
||||||
|
## MCP workflow (ветка ReUI)
|
||||||
|
|
||||||
|
0. **Codegraph** `codegraph_explore` — найти `DataGridCard`, `DomainsFiltersToolbar` и существующие `@/components/reui/*`
|
||||||
|
1. **`get_project_registries`** — должен содержать `@reui`
|
||||||
|
2. **`search_items_in_registries`** с `registries: ["@reui"]`, `query`: `"data grid"`, `"filters"`, `"stepper"` и т.д.
|
||||||
|
3. **`get_item_examples_from_registries`** — полный код перед JSX
|
||||||
|
4. **`get_add_command_for_items`** — точная CLI-команда
|
||||||
|
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
|
||||||
|
6. **Docs:** ReUI `https://reui.io/docs/components/base/<name>` + llms.txt — **не** ui.shadcn.com для `@reui/*`
|
||||||
|
7. Post-add: импорты shadcn-примитивов → `@cfdm/ui/components/*`
|
||||||
|
8. `pnpm install` + `pnpm --filter web build`
|
||||||
|
|
||||||
|
## Размещение и импорты
|
||||||
|
|
||||||
|
| Слой | Путь | Импорт |
|
||||||
|
|------|------|--------|
|
||||||
|
| shadcn primitives | `packages/ui/src/components/` | `@cfdm/ui/components/*` |
|
||||||
|
| ReUI enterprise | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||||
|
| Проектные обёртки | `apps/web/src/components/` | `@/components/data-grid-card` и т.д. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn@latest add @reui/data-grid
|
||||||
|
pnpm dlx shadcn@latest add @reui/filters
|
||||||
|
```
|
||||||
|
|
||||||
|
## Уже установленные ReUI-компоненты
|
||||||
|
|
||||||
|
`badge`, `data-grid/*`, `filters` — в [`apps/web/src/components/reui/`](apps/web/src/components/reui/).
|
||||||
|
|
||||||
|
Перед добавлением дубликата — проверить 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`](apps/web/package.json).
|
||||||
|
|
||||||
|
## Semantic tokens (Styling)
|
||||||
|
|
||||||
|
ReUI расширяет тему shadcn токенами `--success`, `--info`, `--warning`, `--destructive-foreground`, `--invert` — уже в [`packages/ui/src/styles/globals.css`](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
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- Копипаст с reui.io без CLI `add @reui/...`
|
||||||
|
- Класть ReUI в `packages/ui` или импортировать как `@cfdm/ui`
|
||||||
|
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
|
||||||
|
- Raw Tailwind-цвета вместо ReUI semantic `variant`
|
||||||
|
- Писать data-grid/filters с нуля, если есть `@reui/*` и shared-обёртки
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] Задача требует ReUI? (см. матрицу выше)
|
||||||
|
- [ ] MCP search с `registries: ["@reui"]` + examples
|
||||||
|
- [ ] `shadcn add @reui/<name>` из `apps/web`
|
||||||
|
- [ ] Импорты: `@cfdm/ui` для примитивов, `@/components/reui` для ReUI
|
||||||
|
- [ ] Зависимости в `apps/web/package.json`
|
||||||
|
- [ ] `pnpm --filter web build` без ошибок
|
||||||
@@ -20,7 +20,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s
|
|||||||
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
|
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
|
||||||
- Vite + TanStack Router/Query + shadcn **base-nova**
|
- Vite + TanStack Router/Query + shadcn **base-nova**
|
||||||
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
|
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
|
||||||
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять `packages/ui/src/styles/globals.css`
|
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять базовую тему `globals.css`; ReUI semantic tokens — [Styling](https://reui.io/docs/styling)
|
||||||
|
|
||||||
## Backend → UI
|
## Backend → UI
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@cfdm/ui/lib/utils",
|
"utils": "@cfdm/ui/lib/utils",
|
||||||
|
|||||||
@@ -80,4 +80,25 @@ export default defineConfig([
|
|||||||
'no-restricted-syntax': 'off',
|
'no-restricted-syntax': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/components/reui/**/*.{ts,tsx}',
|
||||||
|
'src/components/data-grid-card.tsx',
|
||||||
|
'src/components/data-grid-types.ts',
|
||||||
|
'src/components/data-grid-cells.tsx',
|
||||||
|
'src/components/domains-filters-toolbar.tsx',
|
||||||
|
'src/components/list-filters-bar.tsx',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
'react-hooks/incompatible-library': 'off',
|
||||||
|
'react-hooks/use-memo': 'off',
|
||||||
|
'react-hooks/immutability': 'off',
|
||||||
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
'no-restricted-syntax': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-expressions': 'off',
|
||||||
|
'prefer-const': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@cfdm/shared": "workspace:*",
|
"@cfdm/shared": "workspace:*",
|
||||||
"@cfdm/ui": "workspace:*",
|
"@cfdm/ui": "workspace:*",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
@@ -22,12 +23,15 @@
|
|||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.15",
|
"@tanstack/react-router": "^1.170.15",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"@tanstack/router-vite-plugin": "^1.167.18",
|
"@tanstack/router-vite-plugin": "^1.167.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8",
|
||||||
|
"tw-animate-css": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,15 @@ export function AppSidebar() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" render={<Link to="/" />}>
|
<SidebarMenuButton size="lg" render={<Link to="/" />}>
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<div
|
||||||
<CloudIcon />
|
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<CloudIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className="truncate font-medium">CF Domain Manager</span>
|
<span className="font-semibold">CF Domain Manager</span>
|
||||||
<span className="truncate text-xs">Управление доменами</span>
|
<span className="text-xs text-muted-foreground">Управление доменами</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
flexRender,
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type VisibilityState,
|
||||||
|
type OnChangeFn,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { InboxIcon, Columns3Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
DataGridContainer,
|
||||||
|
} from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||||||
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||||
|
import { EmptyState } from './empty-state'
|
||||||
|
import type { DataGridColumn } from './data-grid-types'
|
||||||
|
|
||||||
|
const PAGINATION_LABELS = {
|
||||||
|
rowsPerPageLabel: 'Строк на странице',
|
||||||
|
info: '{from}–{to} из {count}',
|
||||||
|
previousPageLabel: 'Предыдущая страница',
|
||||||
|
nextPageLabel: 'Следующая страница',
|
||||||
|
pageLabel: 'Страница {page}',
|
||||||
|
previousPagesLabel: 'Предыдущие страницы',
|
||||||
|
nextPagesLabel: 'Следующие страницы',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||||
|
if (headerTitle) return headerTitle
|
||||||
|
if (typeof header === 'string') return header
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return undefined
|
||||||
|
return JSON.parse(raw) as VisibilityState
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { loadStoredColumnVisibility }
|
||||||
|
|
||||||
|
export interface DataGridColumnVisibilityOption {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridColumnVisibilityOptions<T>(
|
||||||
|
cols: DataGridColumn<T>[],
|
||||||
|
): DataGridColumnVisibilityOption[] {
|
||||||
|
return cols
|
||||||
|
.filter((c) => c.enableHiding !== false)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.key,
|
||||||
|
label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridCardProps<TData extends object> {
|
||||||
|
title?: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
|
columns: ColumnDef<TData, unknown>[]
|
||||||
|
data: TData[]
|
||||||
|
/** Ключ строки — функция, возвращающая уникальный id. */
|
||||||
|
rowId?: (row: TData, index: number) => string
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
onRowClick?: (row: TData) => void
|
||||||
|
/** Включить пагинацию. По умолчанию true. */
|
||||||
|
pagination?: boolean
|
||||||
|
/** Размер страницы. По умолчанию 10. */
|
||||||
|
pageSize?: number
|
||||||
|
/** Footer-контент (итоги). */
|
||||||
|
footerContent?: ReactNode
|
||||||
|
/** Плотный layout. */
|
||||||
|
dense?: boolean
|
||||||
|
/** Колонка действий закреплена справа. */
|
||||||
|
pinLastColumn?: boolean
|
||||||
|
/** Сортировка по умолчанию. */
|
||||||
|
initialSorting?: SortingState
|
||||||
|
/** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */
|
||||||
|
virtualization?: boolean
|
||||||
|
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
||||||
|
height?: number
|
||||||
|
/** Включить выбор строк (чекбоксы). */
|
||||||
|
enableRowSelection?: boolean
|
||||||
|
/** Callback при изменении выбора. */
|
||||||
|
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||||
|
/** Показать picker видимости колонок. */
|
||||||
|
enableColumnVisibility?: boolean
|
||||||
|
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
|
||||||
|
columnVisibility?: VisibilityState
|
||||||
|
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||||
|
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
|
||||||
|
columnVisibilityTrigger?: boolean
|
||||||
|
/** Ключ localStorage для сохранения видимости колонок. */
|
||||||
|
columnVisibilityStorageKey?: string
|
||||||
|
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||||
|
initialColumnVisibility?: VisibilityState
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridSectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
if (!title && !description && !actions) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
{title ? <h3 className="text-sm font-medium">{title}</h3> : null}
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridPaginationBar() {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border px-4 py-2.5">
|
||||||
|
<DataGridPagination {...PAGINATION_LABELS} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataGridCardBody<TData extends object>({
|
||||||
|
table,
|
||||||
|
data,
|
||||||
|
emptyTitle,
|
||||||
|
onRowClick,
|
||||||
|
dense,
|
||||||
|
virtualization,
|
||||||
|
height,
|
||||||
|
footerContent,
|
||||||
|
showPagination,
|
||||||
|
enableColumnVisibility,
|
||||||
|
}: {
|
||||||
|
table: ReturnType<typeof useReactTable<TData>>
|
||||||
|
data: TData[]
|
||||||
|
emptyTitle: string
|
||||||
|
onRowClick?: (row: TData) => void
|
||||||
|
dense: boolean
|
||||||
|
virtualization: boolean
|
||||||
|
height: number
|
||||||
|
footerContent?: ReactNode
|
||||||
|
showPagination: boolean
|
||||||
|
enableColumnVisibility: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={data.length}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
emptyMessage={emptyTitle}
|
||||||
|
tableLayout={{
|
||||||
|
dense,
|
||||||
|
stripped: true,
|
||||||
|
rowBorder: true,
|
||||||
|
headerSticky: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
width: 'auto',
|
||||||
|
columnsVisibility: enableColumnVisibility,
|
||||||
|
columnsResizable: false,
|
||||||
|
columnsPinnable: false,
|
||||||
|
columnsMovable: false,
|
||||||
|
rowsDraggable: false,
|
||||||
|
rowsPinnable: false,
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
header: 'text-xs font-medium text-muted-foreground',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DataGridContainer border={false}>
|
||||||
|
{virtualization ? (
|
||||||
|
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||||
|
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
) : (
|
||||||
|
<DataGridTable footerContent={footerContent} />
|
||||||
|
)}
|
||||||
|
</DataGridContainer>
|
||||||
|
{showPagination ? <DataGridPaginationBar /> : null}
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridCard<TData extends object>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowId,
|
||||||
|
emptyTitle = 'Нет записей',
|
||||||
|
emptyDescription,
|
||||||
|
emptyAction,
|
||||||
|
onRowClick,
|
||||||
|
pagination,
|
||||||
|
pageSize = 10,
|
||||||
|
footerContent,
|
||||||
|
dense = true,
|
||||||
|
pinLastColumn = false,
|
||||||
|
initialSorting,
|
||||||
|
virtualization = false,
|
||||||
|
height = 480,
|
||||||
|
enableRowSelection = false,
|
||||||
|
onRowSelectionChange,
|
||||||
|
enableColumnVisibility = false,
|
||||||
|
columnVisibility: columnVisibilityProp,
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
columnVisibilityTrigger,
|
||||||
|
columnVisibilityStorageKey,
|
||||||
|
initialColumnVisibility,
|
||||||
|
className,
|
||||||
|
}: DataGridCardProps<TData>) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
|
||||||
|
const stored = columnVisibilityStorageKey
|
||||||
|
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||||
|
: undefined
|
||||||
|
return { ...initialColumnVisibility, ...stored }
|
||||||
|
})
|
||||||
|
|
||||||
|
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||||
|
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
|
||||||
|
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
|
||||||
|
? (onColumnVisibilityChange ?? (() => undefined))
|
||||||
|
: setInternalColumnVisibility
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||||
|
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||||
|
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||||
|
|
||||||
|
const selectColumn: ColumnDef<TData, unknown> = {
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Выбрать все"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Выбрать строку"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
meta: { cellClassName: 'w-10' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
|
||||||
|
|
||||||
|
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
||||||
|
|
||||||
|
const showPagination = pagination ?? true
|
||||||
|
|
||||||
|
const table = useReactTable<TData>({
|
||||||
|
data,
|
||||||
|
columns: tableColumns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
...(enableRowSelection ? { rowSelection } : {}),
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: enableRowSelection
|
||||||
|
? (updater) => {
|
||||||
|
setRowSelection((prev) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||||
|
if (onRowSelectionChange && rowId) {
|
||||||
|
const ids = Object.keys(next).filter((k) => next[k])
|
||||||
|
onRowSelectionChange(ids)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||||||
|
initialState: {
|
||||||
|
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
|
||||||
|
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
|
||||||
|
},
|
||||||
|
getRowId: rowId
|
||||||
|
? (row, index) => rowId(row, index)
|
||||||
|
: undefined,
|
||||||
|
enableColumnPinning: pinLastColumn,
|
||||||
|
enableRowSelection,
|
||||||
|
enableHiding: enableColumnVisibility,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showColumnVisibilityTrigger =
|
||||||
|
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||||||
|
|
||||||
|
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||||||
|
<DataGridColumnVisibility
|
||||||
|
table={table}
|
||||||
|
trigger={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Columns3Icon data-icon="inline-start" />
|
||||||
|
Колонки
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const headerActions = actions ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{columnVisibilityAction}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
columnVisibilityAction
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
|
{hasHeader ? (
|
||||||
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||||
|
) : null}
|
||||||
|
<EmptyState icon={InboxIcon} title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridBody = (
|
||||||
|
<DataGridCardBody
|
||||||
|
table={table}
|
||||||
|
data={data}
|
||||||
|
emptyTitle={emptyTitle}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
dense={dense}
|
||||||
|
virtualization={virtualization}
|
||||||
|
height={height}
|
||||||
|
footerContent={footerContent}
|
||||||
|
showPagination={showPagination}
|
||||||
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-3', className)}>
|
||||||
|
{hasHeader ? (
|
||||||
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||||
|
) : null}
|
||||||
|
{gridBody}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||||
|
export function columnDefFromDataGrid<T>(
|
||||||
|
cols: DataGridColumn<T>[],
|
||||||
|
): ColumnDef<T, unknown>[] {
|
||||||
|
return cols.map((c) => {
|
||||||
|
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||||
|
const Icon = c.icon
|
||||||
|
const sortable = c.sortable ?? Boolean(Icon)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: c.key,
|
||||||
|
...(sortable
|
||||||
|
? {
|
||||||
|
accessorFn: c.sortValue
|
||||||
|
? (row: T) => c.sortValue!(row)
|
||||||
|
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
header: Icon
|
||||||
|
? ({ column }) => (
|
||||||
|
<DataGridColumnHeader
|
||||||
|
column={column}
|
||||||
|
title={title}
|
||||||
|
icon={<Icon />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: () => c.header,
|
||||||
|
cell: ({ row }) => c.cell(row.original, row.index),
|
||||||
|
enableSorting: sortable,
|
||||||
|
enableHiding: c.enableHiding ?? true,
|
||||||
|
meta: {
|
||||||
|
headerTitle: title || undefined,
|
||||||
|
cellClassName: c.className,
|
||||||
|
headerClassName: c.headerClassName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте columnDefFromDataGrid */
|
||||||
|
export const columnDefFromDataTable = columnDefFromDataGrid
|
||||||
|
|
||||||
|
/** re-export flexRender для удобства использования в колонках. */
|
||||||
|
export { flexRender }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
|
export function dataGridCellStack(
|
||||||
|
primary: ReactNode,
|
||||||
|
secondary?: ReactNode,
|
||||||
|
className?: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
|
||||||
|
{typeof primary === 'string' || typeof primary === 'number' ? (
|
||||||
|
<TruncatedText className="font-medium">{primary}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-medium">{primary}</span>
|
||||||
|
)}
|
||||||
|
{secondary ? (
|
||||||
|
typeof secondary === 'string' || typeof secondary === 'number' ? (
|
||||||
|
<TruncatedText className="max-w-[14rem] text-xs text-muted-foreground">{secondary}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridCellWithIcon(
|
||||||
|
icon: ReactNode,
|
||||||
|
children: ReactNode,
|
||||||
|
className?: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridCellWithFlag(
|
||||||
|
flag: ReactNode,
|
||||||
|
primary: ReactNode,
|
||||||
|
secondary?: ReactNode,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex size-4 shrink-0 items-center justify-center">{flag}</span>
|
||||||
|
{secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface DataGridColumn<T> {
|
||||||
|
key: string
|
||||||
|
header: ReactNode
|
||||||
|
cell: (row: T, index: number) => ReactNode
|
||||||
|
icon?: LucideIcon
|
||||||
|
sortable?: boolean
|
||||||
|
sortValue?: (row: T) => string | number
|
||||||
|
headerTitle?: string
|
||||||
|
className?: string
|
||||||
|
headerClassName?: string
|
||||||
|
enableHiding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте DataGridColumn */
|
||||||
|
export type DataTableColumn<T> = DataGridColumn<T>
|
||||||
|
|
||||||
|
/** Унифицированные классы колонок для DataGridCard. */
|
||||||
|
export const COL = {
|
||||||
|
num: 'w-28 text-right tabular-nums',
|
||||||
|
date: 'w-32 text-right tabular-nums text-muted-foreground',
|
||||||
|
actions: 'w-24 text-right',
|
||||||
|
} as const
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
|
|
||||||
interface DataTableCardProps {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
toolbar?: React.ReactNode
|
|
||||||
emptyTitle?: string
|
|
||||||
emptyDescription?: string
|
|
||||||
emptyIcon?: LucideIcon
|
|
||||||
emptyAction?: React.ReactNode
|
|
||||||
isEmpty?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
toolbar,
|
|
||||||
emptyTitle,
|
|
||||||
emptyDescription,
|
|
||||||
emptyIcon: EmptyIcon = InboxIcon,
|
|
||||||
emptyAction,
|
|
||||||
isEmpty,
|
|
||||||
}: DataTableCardProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{title}</CardTitle>
|
|
||||||
{description && <CardDescription>{description}</CardDescription>}
|
|
||||||
</CardHeader>
|
|
||||||
{toolbar && !isEmpty && (
|
|
||||||
<div className="flex items-center gap-2 px-6 pb-4">{toolbar}</div>
|
|
||||||
)}
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{isEmpty && emptyTitle ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={EmptyIcon}
|
|
||||||
title={emptyTitle}
|
|
||||||
description={emptyDescription}
|
|
||||||
action={emptyAction}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export type Density = 'compact' | 'comfortable' | 'spacious'
|
|
||||||
|
|
||||||
export const densityRowClass: Record<Density, string> = {
|
|
||||||
compact: '[&_tr]:h-9 [&_tr_td]:py-1 [&_tr_th]:py-1 text-xs',
|
|
||||||
comfortable: '[&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2 text-sm',
|
|
||||||
spacious: '[&_tr]:h-12 [&_tr_td]:py-3 [&_tr_th]:py-3 text-sm',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const densityLabel: Record<Density, string> = {
|
|
||||||
compact: 'Компактная',
|
|
||||||
comfortable: 'Обычная',
|
|
||||||
spacious: 'Просторная',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pageSizeOptions = [10, 20, 50] as const
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import type { Table } from '@tanstack/react-table'
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from '@cfdm/ui/components/pagination'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@cfdm/ui/components/select'
|
|
||||||
import { pageSizeOptions } from '@/components/data-table-density'
|
|
||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
selectedCount?: number
|
|
||||||
pageSizeLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPageRange(current: number, total: number): (number | 'ellipsis')[] {
|
|
||||||
if (total <= 7) {
|
|
||||||
return Array.from({ length: total }, (_, i) => i + 1)
|
|
||||||
}
|
|
||||||
const pages: (number | 'ellipsis')[] = [1]
|
|
||||||
const start = Math.max(2, current - 1)
|
|
||||||
const end = Math.min(total - 1, current + 1)
|
|
||||||
if (start > 2) pages.push('ellipsis')
|
|
||||||
for (let i = start; i <= end; i++) pages.push(i)
|
|
||||||
if (end < total - 1) pages.push('ellipsis')
|
|
||||||
pages.push(total)
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTablePagination<TData>({
|
|
||||||
table,
|
|
||||||
selectedCount,
|
|
||||||
pageSizeLabel = 'Строк',
|
|
||||||
}: DataTablePaginationProps<TData>) {
|
|
||||||
const { pageIndex, pageSize } = table.getState().pagination
|
|
||||||
const pageCount = table.getPageCount()
|
|
||||||
|
|
||||||
const total = table.getFilteredRowModel().rows.length
|
|
||||||
const from = total === 0 ? 0 : pageIndex * pageSize + 1
|
|
||||||
const to = Math.min((pageIndex + 1) * pageSize, total)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-between gap-4 px-4 py-3 text-sm sm:flex-row">
|
|
||||||
<div className="text-muted-foreground tabular-nums">
|
|
||||||
{selectedCount != null && selectedCount > 0
|
|
||||||
? `Выбрано: ${selectedCount} из ${total}`
|
|
||||||
: `${from}–${to} из ${total}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground">{pageSizeLabel}</span>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(val) => table.setPageSize(Number(val))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{pageSizeOptions.map((size) => (
|
|
||||||
<SelectItem key={size} value={String(size)}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{pageCount > 1 && (
|
|
||||||
<Pagination className="mx-0 w-auto">
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
text="Назад"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
aria-disabled={!table.getCanPreviousPage()}
|
|
||||||
className={!table.getCanPreviousPage() ? 'pointer-events-none opacity-50' : undefined}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
{buildPageRange(pageIndex + 1, pageCount).map((page, idx) =>
|
|
||||||
page === 'ellipsis' ? (
|
|
||||||
<PaginationItem key={`ellipsis-${idx}`}>
|
|
||||||
<PaginationEllipsis />
|
|
||||||
</PaginationItem>
|
|
||||||
) : (
|
|
||||||
<PaginationItem key={page}>
|
|
||||||
<PaginationLink
|
|
||||||
isActive={page === pageIndex + 1}
|
|
||||||
onClick={() => table.setPageIndex(page - 1)}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
text="Вперёд"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
aria-disabled={!table.getCanNextPage()}
|
|
||||||
className={!table.getCanNextPage() ? 'pointer-events-none opacity-50' : undefined}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { Table } from '@tanstack/react-table'
|
|
||||||
import { ColumnsIcon, SearchIcon } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
} from '@cfdm/ui/components/input-group'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
|
||||||
densityLabel,
|
|
||||||
type Density,
|
|
||||||
} from '@/components/data-table-density'
|
|
||||||
|
|
||||||
interface DataTableToolbarProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
searchPlaceholder?: string
|
|
||||||
searchValue?: string
|
|
||||||
onSearchChange?: (value: string) => void
|
|
||||||
filters?: ReactNode
|
|
||||||
density: Density
|
|
||||||
onDensityChange: (density: Density) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableToolbar<TData>({
|
|
||||||
table,
|
|
||||||
searchPlaceholder = 'Поиск…',
|
|
||||||
searchValue,
|
|
||||||
onSearchChange,
|
|
||||||
filters,
|
|
||||||
density,
|
|
||||||
onDensityChange,
|
|
||||||
}: DataTableToolbarProps<TData>) {
|
|
||||||
const columns = table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((col) => typeof col.getCanHide === 'function' && col.getCanHide())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
{onSearchChange && (
|
|
||||||
<InputGroup className="max-w-sm">
|
|
||||||
<InputGroupAddon>
|
|
||||||
<SearchIcon />
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={searchValue ?? ''}
|
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
)}
|
|
||||||
{filters}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={<AppButton variant="outline" size="sm" />}
|
|
||||||
>
|
|
||||||
<ColumnsIcon data-icon="inline-start" />
|
|
||||||
Колонки
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
|
||||||
<DropdownMenuLabel>Видимость</DropdownMenuLabel>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
||||||
>
|
|
||||||
{typeof column.columnDef.header === 'string'
|
|
||||||
? column.columnDef.header
|
|
||||||
: column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={<AppButton variant="outline" size="sm" />}
|
|
||||||
>
|
|
||||||
Плотность
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
|
||||||
<DropdownMenuLabel>Плотность строк</DropdownMenuLabel>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={density}
|
|
||||||
onValueChange={(val) => onDensityChange(val as Density)}
|
|
||||||
>
|
|
||||||
{(Object.keys(densityLabel) as Density[]).map((d) => (
|
|
||||||
<DropdownMenuRadioItem key={d} value={d}>
|
|
||||||
{densityLabel[d]}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { flexRender } from '@tanstack/react-table'
|
|
||||||
import type { Table as TableType } from '@tanstack/react-table'
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@cfdm/ui/components/empty'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@cfdm/ui/components/table'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
import { DataTablePagination } from '@/components/data-table-pagination'
|
|
||||||
import { DataTableToolbar } from '@/components/data-table-toolbar'
|
|
||||||
import { densityRowClass, type Density } from '@/components/data-table-density'
|
|
||||||
|
|
||||||
interface DataTableViewProps<TData> {
|
|
||||||
table: TableType<TData>
|
|
||||||
density: Density
|
|
||||||
onDensityChange: (density: Density) => void
|
|
||||||
searchPlaceholder?: string
|
|
||||||
searchValue?: string
|
|
||||||
onSearchChange?: (value: string) => void
|
|
||||||
filters?: ReactNode
|
|
||||||
emptyTitle?: string
|
|
||||||
emptyDescription?: string
|
|
||||||
selectedCount?: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableView<TData>({
|
|
||||||
table,
|
|
||||||
density,
|
|
||||||
onDensityChange,
|
|
||||||
searchPlaceholder,
|
|
||||||
searchValue,
|
|
||||||
onSearchChange,
|
|
||||||
filters,
|
|
||||||
emptyTitle = 'Ничего не найдено',
|
|
||||||
emptyDescription = 'Измените фильтры или поисковый запрос',
|
|
||||||
selectedCount,
|
|
||||||
className,
|
|
||||||
}: DataTableViewProps<TData>) {
|
|
||||||
const columns = table.getAllColumns()
|
|
||||||
const showToolbar = onSearchChange || filters || columns.some((c) => c.getCanHide?.())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col', className)}>
|
|
||||||
{showToolbar && (
|
|
||||||
<DataTableToolbar
|
|
||||||
table={table}
|
|
||||||
density={density}
|
|
||||||
onDensityChange={onDensityChange}
|
|
||||||
searchPlaceholder={searchPlaceholder}
|
|
||||||
searchValue={searchValue}
|
|
||||||
onSearchChange={onSearchChange}
|
|
||||||
filters={filters}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="relative overflow-auto">
|
|
||||||
<Table className={densityRowClass[density]}>
|
|
||||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
className={header.column.id === 'actions' ? 'w-12 text-right' : undefined}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.length > 0 ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className={cell.column.id === 'actions' ? 'text-right' : undefined}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-48 p-0">
|
|
||||||
<Empty className="border-0">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
|
||||||
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DataTablePagination table={table} selectedCount={selectedCount} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import {
|
||||||
|
DataGridCard,
|
||||||
|
columnDefFromDataGrid,
|
||||||
|
} from '@/components/data-grid-card'
|
||||||
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
|
||||||
|
interface DnsRecordsDataGridProps {
|
||||||
|
records: DnsRecord[]
|
||||||
|
onDelete: (recordId: number) => void
|
||||||
|
isDeleting?: boolean
|
||||||
|
healthByIp?: Record<string, IpHealthStatus>
|
||||||
|
search?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
hideSearch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||||
|
up: 'bg-success',
|
||||||
|
degraded: 'bg-warning',
|
||||||
|
down: 'bg-destructive',
|
||||||
|
unknown: 'bg-muted-foreground/40',
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||||
|
up: 'OK',
|
||||||
|
degraded: 'Деград.',
|
||||||
|
down: 'Down',
|
||||||
|
unknown: '—',
|
||||||
|
}
|
||||||
|
|
||||||
|
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||||
|
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||||
|
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||||
|
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
||||||
|
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||||
|
healthDotClass[health.status],
|
||||||
|
)}
|
||||||
|
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DnsRecordsDataGrid({
|
||||||
|
records,
|
||||||
|
onDelete,
|
||||||
|
isDeleting = false,
|
||||||
|
healthByIp,
|
||||||
|
search: controlledSearch,
|
||||||
|
onSearchChange,
|
||||||
|
hideSearch = false,
|
||||||
|
}: DnsRecordsDataGridProps) {
|
||||||
|
const [internalSearch, setInternalSearch] = useState('')
|
||||||
|
const search = controlledSearch ?? internalSearch
|
||||||
|
const setSearch = onSearchChange ?? setInternalSearch
|
||||||
|
|
||||||
|
const filteredRecords = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return records
|
||||||
|
return records.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(query) ||
|
||||||
|
r.content.toLowerCase().includes(query) ||
|
||||||
|
r.record_type.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [records, search])
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<DnsRecord>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'record_type',
|
||||||
|
header: 'Тип',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.record_type,
|
||||||
|
cell: (row) => <Badge variant="outline">{row.record_type}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Имя',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.name,
|
||||||
|
cell: (row) => <span className="font-medium">{row.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'content',
|
||||||
|
header: 'Значение',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.content,
|
||||||
|
cell: (row) => {
|
||||||
|
const health = healthByIp?.[row.content]
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{health ? <IpHealthDot health={health} /> : null}
|
||||||
|
<span className="truncate font-mono text-sm">{row.content}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ttl',
|
||||||
|
header: 'TTL',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.ttl,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
cell: (row) => row.ttl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sync_status',
|
||||||
|
header: 'Синхронизация',
|
||||||
|
cell: (row) => <StatusBadge status={row.sync_status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableHiding: false,
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Удалить DNS-запись?"
|
||||||
|
description={`Запись ${row.name} (${row.record_type}) будет удалена из зоны.`}
|
||||||
|
onConfirm={() => onDelete(row.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[healthByIp, isDeleting, onDelete],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridCard
|
||||||
|
title="DNS-записи"
|
||||||
|
description="Записи в зоне"
|
||||||
|
columns={columnDefFromDataGrid(columns)}
|
||||||
|
data={filteredRecords}
|
||||||
|
rowId={(row) => String(row.id)}
|
||||||
|
emptyTitle="DNS-записи не найдены"
|
||||||
|
emptyDescription="Создайте запись или измените фильтры"
|
||||||
|
pinLastColumn
|
||||||
|
actions={
|
||||||
|
hideSearch ? undefined : (
|
||||||
|
<ListFiltersBar
|
||||||
|
search={{
|
||||||
|
value: search,
|
||||||
|
onChange: setSearch,
|
||||||
|
placeholder: 'Поиск по имени или значению…',
|
||||||
|
}}
|
||||||
|
shown={filteredRecords.length}
|
||||||
|
total={records.length}
|
||||||
|
showReset={Boolean(search.trim())}
|
||||||
|
onReset={() => setSearch('')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { TableCard } from '@/components/table-card'
|
|
||||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
|
||||||
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
|
||||||
import { formatDate } from '@/lib/format'
|
|
||||||
import { AppBadge } from '@/components/app-badge'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@cfdm/ui/components/tooltip'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@cfdm/ui/components/table'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
interface DnsRecordsTableProps {
|
|
||||||
records: DnsRecord[]
|
|
||||||
onDelete: (recordId: number) => void
|
|
||||||
isDeleting?: boolean
|
|
||||||
healthByIp?: Record<string, IpHealthStatus>
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
|
||||||
up: 'bg-emerald-500',
|
|
||||||
degraded: 'bg-amber-500',
|
|
||||||
down: 'bg-rose-500',
|
|
||||||
unknown: 'bg-muted-foreground/40',
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
|
||||||
up: 'OK',
|
|
||||||
degraded: 'Деград.',
|
|
||||||
down: 'Down',
|
|
||||||
unknown: '—',
|
|
||||||
}
|
|
||||||
|
|
||||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
|
||||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
|
||||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
|
||||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
|
||||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
|
||||||
healthDotClass[health.status],
|
|
||||||
)}
|
|
||||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DnsRecordCells({
|
|
||||||
record,
|
|
||||||
onDelete,
|
|
||||||
isDeleting,
|
|
||||||
healthByIp,
|
|
||||||
}: {
|
|
||||||
record: DnsRecord
|
|
||||||
onDelete: (recordId: number) => void
|
|
||||||
isDeleting?: boolean
|
|
||||||
healthByIp?: Record<string, IpHealthStatus>
|
|
||||||
}) {
|
|
||||||
const health = healthByIp?.[record.content]
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TableCell className="max-w-xs truncate font-mono text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{health ? <IpHealthDot health={health} /> : null}
|
|
||||||
<span className="truncate">{record.content}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="tabular-nums">{record.ttl}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={record.sync_status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<AppButton variant="destructive" size="sm" disabled={isDeleting}>
|
|
||||||
Удалить
|
|
||||||
</AppButton>
|
|
||||||
}
|
|
||||||
title="Удалить DNS-запись?"
|
|
||||||
description={`Запись ${record.name} (${record.record_type}) будет удалена из зоны.`}
|
|
||||||
onConfirm={() => onDelete(record.id)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SingleRecordRow({
|
|
||||||
record,
|
|
||||||
onDelete,
|
|
||||||
isDeleting,
|
|
||||||
isFirstGroup,
|
|
||||||
healthByIp,
|
|
||||||
}: {
|
|
||||||
record: DnsRecord
|
|
||||||
onDelete: (recordId: number) => void
|
|
||||||
isDeleting?: boolean
|
|
||||||
isFirstGroup: boolean
|
|
||||||
healthByIp?: Record<string, IpHealthStatus>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow className={cn(!isFirstGroup && 'border-t-4 border-muted')}>
|
|
||||||
<TableCell>{record.record_type}</TableCell>
|
|
||||||
<TableCell className="font-medium">{record.name}</TableCell>
|
|
||||||
<DnsRecordCells
|
|
||||||
record={record}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
healthByIp={healthByIp}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MultiValueGroupRows({
|
|
||||||
group,
|
|
||||||
onDelete,
|
|
||||||
isDeleting,
|
|
||||||
isFirstGroup,
|
|
||||||
healthByIp,
|
|
||||||
}: {
|
|
||||||
group: DnsRecordGroup
|
|
||||||
onDelete: (recordId: number) => void
|
|
||||||
isDeleting?: boolean
|
|
||||||
isFirstGroup: boolean
|
|
||||||
healthByIp?: Record<string, IpHealthStatus>
|
|
||||||
}) {
|
|
||||||
const [first, ...rest] = group.records
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TableRow
|
|
||||||
className={cn(
|
|
||||||
'bg-muted/25',
|
|
||||||
!isFirstGroup && 'border-t-4 border-muted',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TableCell rowSpan={group.records.length} className="align-top">
|
|
||||||
<AppBadge variant="outline">{group.recordType}</AppBadge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell rowSpan={group.records.length} className="align-top font-medium">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span>{group.name}</span>
|
|
||||||
<AppBadge variant="secondary" className="w-fit">
|
|
||||||
{group.records.length} адресов
|
|
||||||
</AppBadge>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<DnsRecordCells
|
|
||||||
record={first}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
healthByIp={healthByIp}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
{rest.map((record) => (
|
|
||||||
<TableRow key={record.id} className="bg-muted/25">
|
|
||||||
<DnsRecordCells
|
|
||||||
record={record}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
healthByIp={healthByIp}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DnsRecordsTable({
|
|
||||||
records,
|
|
||||||
onDelete,
|
|
||||||
isDeleting,
|
|
||||||
healthByIp,
|
|
||||||
}: DnsRecordsTableProps) {
|
|
||||||
const groups = groupDnsRecords(records)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableCard>
|
|
||||||
<div className="relative overflow-auto max-h-[60vh]">
|
|
||||||
<Table className="text-sm [&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2">
|
|
||||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-20">Тип</TableHead>
|
|
||||||
<TableHead>Имя</TableHead>
|
|
||||||
<TableHead>Значение</TableHead>
|
|
||||||
<TableHead className="w-16">TTL</TableHead>
|
|
||||||
<TableHead>Синхронизация</TableHead>
|
|
||||||
<TableHead className="w-28" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{groups.map((group, index) =>
|
|
||||||
group.isMultiValue ? (
|
|
||||||
<MultiValueGroupRows
|
|
||||||
key={group.key}
|
|
||||||
group={group}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
isFirstGroup={index === 0}
|
|
||||||
healthByIp={healthByIp}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SingleRecordRow
|
|
||||||
key={group.records[0].id}
|
|
||||||
record={group.records[0]}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
isFirstGroup={index === 0}
|
|
||||||
healthByIp={healthByIp}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</TableCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+107
-109
@@ -1,22 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import {
|
import { GlobeIcon, MoreHorizontalIcon } from 'lucide-react'
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
getPaginationRowModel,
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import { ArrowUpDownIcon, MoreHorizontalIcon } from 'lucide-react'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import type { DomainListItem } from '@/lib/schemas'
|
|
||||||
import { AppBadge } from '@/components/app-badge'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import { DataTableView } from '@/components/data-table-view'
|
|
||||||
import type { Density } from '@/components/data-table-density'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -30,6 +17,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import {
|
||||||
|
DataGridCard,
|
||||||
|
columnDefFromDataGrid,
|
||||||
|
} from '@/components/data-grid-card'
|
||||||
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
|
import { DomainsFiltersToolbar } from '@/components/domains-filters-toolbar'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import type { DomainListItem } from '@/lib/schemas'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
export type DomainTableRow = DomainListItem
|
export type DomainTableRow = DomainListItem
|
||||||
|
|
||||||
@@ -38,123 +35,130 @@ interface GroupFilterItem {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomainsDataTableProps {
|
export interface DomainsDataGridProps {
|
||||||
data: DomainTableRow[]
|
data: DomainTableRow[]
|
||||||
|
totalCount: number
|
||||||
groupFilterItems: GroupFilterItem[]
|
groupFilterItems: GroupFilterItem[]
|
||||||
groupFilterValue: string
|
groupFilterValue: string
|
||||||
onGroupFilterChange: (value: string | null) => void
|
onGroupFilterChange: (value: string | null) => void
|
||||||
|
search: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
onDelete: (domain: DomainTableRow) => void
|
onDelete: (domain: DomainTableRow) => void
|
||||||
isDeleting?: boolean
|
isDeleting?: boolean
|
||||||
|
emptyAction?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DomainsDataTable({
|
export function DomainsDataGrid({
|
||||||
data,
|
data,
|
||||||
|
totalCount,
|
||||||
groupFilterItems,
|
groupFilterItems,
|
||||||
groupFilterValue,
|
groupFilterValue,
|
||||||
onGroupFilterChange,
|
onGroupFilterChange,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
isDeleting = false,
|
isDeleting = false,
|
||||||
}: DomainsDataTableProps) {
|
emptyAction,
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
}: DomainsDataGridProps) {
|
||||||
const [globalFilter, setGlobalFilter] = useState('')
|
|
||||||
const [density, setDensity] = useState<Density>('comfortable')
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
|
const filteredData = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return data
|
||||||
|
return data.filter(
|
||||||
|
(d) =>
|
||||||
|
d.zone_name.toLowerCase().includes(query) ||
|
||||||
|
(d.group_name ?? '').toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [data, search])
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<DomainTableRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'zone_name',
|
key: 'zone_name',
|
||||||
header: ({ column }) => (
|
header: 'Зона',
|
||||||
<AppButton
|
icon: GlobeIcon,
|
||||||
variant="ghost"
|
sortable: true,
|
||||||
className="-ml-3 h-8"
|
sortValue: (row) => row.zone_name,
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
cell: (row) => (
|
||||||
>
|
<Button
|
||||||
Зона
|
|
||||||
<ArrowUpDownIcon />
|
|
||||||
</AppButton>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<AppButton
|
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 font-medium"
|
className="h-auto p-0 font-medium"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={
|
render={
|
||||||
<Link
|
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
||||||
to="/domains/$domainId"
|
|
||||||
params={{ domainId: String(row.original.id) }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{row.original.zone_name}
|
{row.zone_name}
|
||||||
</AppButton>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'group',
|
key: 'group',
|
||||||
accessorFn: (row) => row.group_name ?? 'Без группы',
|
|
||||||
header: 'Группа',
|
header: 'Группа',
|
||||||
cell: ({ row }) => {
|
sortable: true,
|
||||||
const domain = row.original
|
sortValue: (row) => row.group_name ?? 'Без группы',
|
||||||
if (domain.group_id && domain.group_name) {
|
cell: (row) => {
|
||||||
|
if (row.group_id && row.group_name) {
|
||||||
return (
|
return (
|
||||||
<AppButton
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0"
|
className="h-auto p-0"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={
|
render={
|
||||||
<Link
|
<Link
|
||||||
to="/groups/$groupId"
|
to="/groups/$groupId"
|
||||||
params={{ groupId: String(domain.group_id) }}
|
params={{ groupId: String(row.group_id) }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{domain.group_name}
|
{row.group_name}
|
||||||
</AppButton>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <AppBadge variant="outline">Без группы</AppBadge>
|
return <Badge variant="outline">Без группы</Badge>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'service_count',
|
key: 'service_count',
|
||||||
header: 'Сервисы',
|
header: 'Сервисы',
|
||||||
cell: ({ row }) => (
|
sortable: true,
|
||||||
<AppButton
|
sortValue: (row) => row.service_count,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 tabular-nums"
|
className="h-auto p-0 tabular-nums"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/services" search={{ domainId: row.original.id }} />}
|
render={<Link to="/services" search={{ domainId: row.id }} />}
|
||||||
>
|
>
|
||||||
{row.original.service_count}
|
{row.service_count}
|
||||||
</AppButton>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
key: 'status',
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
cell: (row) => <StatusBadge status={row.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_synced_at',
|
key: 'last_synced_at',
|
||||||
header: 'Синхронизация',
|
header: 'Синхронизация',
|
||||||
cell: ({ row }) => (
|
sortable: true,
|
||||||
<span className="text-muted-foreground tabular-nums">
|
sortValue: (row) => row.last_synced_at ?? '',
|
||||||
{row.original.last_synced_at ?? '—'}
|
className: 'text-muted-foreground tabular-nums',
|
||||||
</span>
|
cell: (row) => row.last_synced_at ?? '—',
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
className: 'text-right',
|
||||||
cell: ({ row }) => (
|
cell: (row) => (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||||
<AppButton variant="outline" size="icon" className="size-8" />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
<span className="sr-only">Действия</span>
|
<span className="sr-only">Действия</span>
|
||||||
@@ -162,10 +166,7 @@ export function DomainsDataTable({
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
render={
|
render={
|
||||||
<Link
|
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
||||||
to="/domains/$domainId"
|
|
||||||
params={{ domainId: String(row.original.id) }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Обзор
|
Обзор
|
||||||
@@ -174,7 +175,7 @@ export function DomainsDataTable({
|
|||||||
render={
|
render={
|
||||||
<Link
|
<Link
|
||||||
to="/domains/$domainId/dns"
|
to="/domains/$domainId/dns"
|
||||||
params={{ domainId: String(row.original.id) }}
|
params={{ domainId: String(row.id) }}
|
||||||
search={{ host: undefined }}
|
search={{ host: undefined }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,7 @@ export function DomainsDataTable({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
onClick={() => setDeleteTarget(row.original)}
|
onClick={() => setDeleteTarget(row)}
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -197,28 +198,9 @@ export function DomainsDataTable({
|
|||||||
[isDeleting],
|
[isDeleting],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const groupSelect = (
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
state: { sorting, globalFilter },
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
globalFilterFn: (row, _columnId, filterValue: string) => {
|
|
||||||
const value = filterValue.toLowerCase()
|
|
||||||
const zone = row.original.zone_name.toLowerCase()
|
|
||||||
const group = (row.original.group_name ?? '').toLowerCase()
|
|
||||||
return zone.includes(value) || group.includes(value)
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
initialState: { pagination: { pageSize: 10 } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterSelect = (
|
|
||||||
<Select
|
<Select
|
||||||
items={groupFilterItems}
|
items={[{ label: 'Все группы', value: 'all' }, ...groupFilterItems]}
|
||||||
value={groupFilterValue || 'all'}
|
value={groupFilterValue || 'all'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!value || value === 'all') {
|
if (!value || value === 'all') {
|
||||||
@@ -242,18 +224,34 @@ export function DomainsDataTable({
|
|||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(groupFilterValue) || Boolean(search.trim())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTableView
|
<DataGridCard
|
||||||
table={table}
|
title="Список доменов"
|
||||||
density={density}
|
description="Импортированные зоны Cloudflare"
|
||||||
onDensityChange={setDensity}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
searchPlaceholder="Поиск по зоне или группе…"
|
data={filteredData}
|
||||||
searchValue={globalFilter}
|
rowId={(row) => String(row.id)}
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
filters={filterSelect}
|
|
||||||
emptyTitle="Домены не найдены"
|
emptyTitle="Домены не найдены"
|
||||||
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
|
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
|
||||||
|
emptyAction={emptyAction}
|
||||||
|
pinLastColumn
|
||||||
|
actions={
|
||||||
|
<DomainsFiltersToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
groupSelect={groupSelect}
|
||||||
|
shownCount={filteredData.length}
|
||||||
|
totalCount={totalCount}
|
||||||
|
hasActiveFilters={hasActiveFilters}
|
||||||
|
onReset={() => {
|
||||||
|
onSearchChange('')
|
||||||
|
onGroupFilterChange(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||||
|
import {
|
||||||
|
Filters,
|
||||||
|
createFilter,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
|
||||||
|
const STATUS_FIELD: FilterFieldConfig = {
|
||||||
|
key: 'status',
|
||||||
|
label: 'Статус',
|
||||||
|
type: 'multiselect',
|
||||||
|
options: [
|
||||||
|
{ value: 'active', label: 'Активен' },
|
||||||
|
{ value: 'synced', label: 'Синхронизировано' },
|
||||||
|
{ value: 'pending_push', label: 'Ожидает отправки' },
|
||||||
|
{ value: 'error', label: 'Ошибка' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainsFiltersToolbarProps {
|
||||||
|
search: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
groupSelect: ReactNode
|
||||||
|
statusFilters?: Filter[]
|
||||||
|
onStatusFiltersChange?: (filters: Filter[]) => void
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainsFiltersToolbar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
groupSelect,
|
||||||
|
statusFilters = [],
|
||||||
|
onStatusFiltersChange,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
|
hasActiveFilters,
|
||||||
|
onReset,
|
||||||
|
}: DomainsFiltersToolbarProps) {
|
||||||
|
return (
|
||||||
|
<ListFiltersBar
|
||||||
|
search={{
|
||||||
|
value: search,
|
||||||
|
onChange: onSearchChange,
|
||||||
|
placeholder: 'Поиск по зоне или группе…',
|
||||||
|
}}
|
||||||
|
controls={
|
||||||
|
<>
|
||||||
|
{groupSelect}
|
||||||
|
{onStatusFiltersChange ? (
|
||||||
|
<Filters
|
||||||
|
filters={statusFilters}
|
||||||
|
fields={[STATUS_FIELD]}
|
||||||
|
onChange={onStatusFiltersChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
shown={shownCount}
|
||||||
|
total={totalCount}
|
||||||
|
showReset={hasActiveFilters}
|
||||||
|
onReset={onReset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusFilter(value: string): Filter {
|
||||||
|
return createFilter('status', 'is', [value])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyStatusFilters<T extends { status: string }>(
|
||||||
|
rows: T[],
|
||||||
|
filters: Filter[],
|
||||||
|
): T[] {
|
||||||
|
const statusFilter = filters.find((f) => f.field === 'status')
|
||||||
|
if (!statusFilter?.values?.length) return rows
|
||||||
|
const allowed = new Set(statusFilter.values.map(String))
|
||||||
|
return rows.filter((row) => allowed.has(row.status))
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
|
||||||
AppCard,
|
|
||||||
AppCardAction,
|
|
||||||
AppCardDescription,
|
|
||||||
AppCardFooter,
|
|
||||||
AppCardHeader,
|
|
||||||
AppCardTitle,
|
|
||||||
} from '@/components/app-card'
|
|
||||||
|
|
||||||
type TrendDirection = 'up' | 'down' | 'flat'
|
|
||||||
|
|
||||||
export interface KpiTrend {
|
|
||||||
value: string
|
|
||||||
direction: TrendDirection
|
|
||||||
hint?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KpiCardProps {
|
|
||||||
label: string
|
|
||||||
value: number | string
|
|
||||||
icon: LucideIcon
|
|
||||||
trend?: KpiTrend
|
|
||||||
actionLabel?: string
|
|
||||||
actionRender?: ComponentProps<typeof AppButton>['render']
|
|
||||||
onAction?: () => void
|
|
||||||
footer?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const trendIcon: Record<TrendDirection, LucideIcon> = {
|
|
||||||
up: TrendingUpIcon,
|
|
||||||
down: TrendingDownIcon,
|
|
||||||
flat: MinusIcon,
|
|
||||||
}
|
|
||||||
|
|
||||||
const trendTone: Record<TrendDirection, string> = {
|
|
||||||
up: 'text-success',
|
|
||||||
down: 'text-destructive',
|
|
||||||
flat: 'text-muted-foreground',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KpiCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon: Icon,
|
|
||||||
trend,
|
|
||||||
actionLabel,
|
|
||||||
actionRender,
|
|
||||||
onAction,
|
|
||||||
footer,
|
|
||||||
}: KpiCardProps) {
|
|
||||||
const TrendIcon = trend ? trendIcon[trend.direction] : null
|
|
||||||
return (
|
|
||||||
<AppCard className="gap-0">
|
|
||||||
<AppCardHeader>
|
|
||||||
<AppCardDescription>{label}</AppCardDescription>
|
|
||||||
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
|
||||||
{value}
|
|
||||||
</AppCardTitle>
|
|
||||||
<AppCardAction>
|
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
|
||||||
</AppCardAction>
|
|
||||||
</AppCardHeader>
|
|
||||||
<AppCardFooter className="flex items-center justify-between gap-2 text-sm">
|
|
||||||
{footer ??
|
|
||||||
(trend ? (
|
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
|
||||||
{TrendIcon && (
|
|
||||||
<TrendIcon
|
|
||||||
className={`size-3.5 ${trendTone[trend.direction]}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="tabular-nums">{trend.value}</span>
|
|
||||||
{trend.hint && (
|
|
||||||
<span className="text-muted-foreground/70">· {trend.hint}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
))}
|
|
||||||
{actionLabel && (actionRender || onAction) && (
|
|
||||||
<AppButton
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0"
|
|
||||||
nativeButton={actionRender ? false : undefined}
|
|
||||||
render={actionRender}
|
|
||||||
onClick={onAction}
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
</AppButton>
|
|
||||||
)}
|
|
||||||
</AppCardFooter>
|
|
||||||
</AppCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,11 @@ import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
|||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider
|
<SidebarProvider>
|
||||||
style={
|
|
||||||
{
|
|
||||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
|
||||||
'--header-height': 'calc(var(--spacing) * 12)',
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex flex-1 flex-col">{children}</div>
|
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@cfdm/ui/components/breadcrumb'
|
} from '@cfdm/ui/components/breadcrumb'
|
||||||
import { AppSeparator } from '@/components/app-separator'
|
import { ModeToggle } from '@/components/mode-toggle'
|
||||||
|
import { Separator } from '@cfdm/ui/components/separator'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
@@ -83,14 +84,10 @@ export function SiteHeader() {
|
|||||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<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">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<SidebarTrigger className="-ml-1" />
|
||||||
<SidebarTrigger className="-ml-1" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<AppSeparator
|
<Breadcrumb>
|
||||||
orientation="vertical"
|
|
||||||
className="mr-2 data-[orientation=vertical]:h-4"
|
|
||||||
/>
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
@@ -115,6 +112,8 @@ export function SiteHeader() {
|
|||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { SearchIcon, XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface FilterChip {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFiltersSearchProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder: string
|
||||||
|
className?: string
|
||||||
|
name?: string
|
||||||
|
autoComplete?: string
|
||||||
|
spellCheck?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListFiltersSearch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
name,
|
||||||
|
autoComplete = 'off',
|
||||||
|
spellCheck = false,
|
||||||
|
}: ListFiltersSearchProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('relative w-full', className)}>
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
name={name}
|
||||||
|
spellCheck={spellCheck}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterActiveChips({ chips }: { chips: FilterChip[] }) {
|
||||||
|
if (chips.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
|
||||||
|
<span className="max-w-[12rem] truncate">{chip.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={chip.onRemove}
|
||||||
|
className="rounded-sm p-0.5 hover:bg-muted"
|
||||||
|
aria-label={`Убрать фильтр: ${chip.label}`}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterResultsCount({
|
||||||
|
shown,
|
||||||
|
total,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
shown: number
|
||||||
|
total: number
|
||||||
|
suffix?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Показано {shown} из {total}
|
||||||
|
</span>
|
||||||
|
{suffix ? <span className="text-muted-foreground/80">{suffix}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
|
||||||
|
if (!visible) return null
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onClick}>
|
||||||
|
<XIcon data-icon="inline-start" />
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterToggleChipProps {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={active ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(active && 'border-primary/40')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFiltersBarProps {
|
||||||
|
search?: ListFiltersSearchProps
|
||||||
|
controls?: ReactNode
|
||||||
|
chips?: FilterChip[]
|
||||||
|
shown?: number
|
||||||
|
total?: number
|
||||||
|
resultsSuffix?: ReactNode
|
||||||
|
onReset?: () => void
|
||||||
|
showReset?: boolean
|
||||||
|
toggles?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListFiltersBar({
|
||||||
|
search,
|
||||||
|
controls,
|
||||||
|
chips,
|
||||||
|
shown,
|
||||||
|
total,
|
||||||
|
resultsSuffix,
|
||||||
|
onReset,
|
||||||
|
showReset,
|
||||||
|
toggles,
|
||||||
|
}: ListFiltersBarProps) {
|
||||||
|
const hasMeta =
|
||||||
|
chips?.length ||
|
||||||
|
(shown != null && total != null) ||
|
||||||
|
resultsSuffix ||
|
||||||
|
(showReset && onReset)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{search ? <ListFiltersSearch {...search} /> : null}
|
||||||
|
{controls || toggles ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{controls}
|
||||||
|
{toggles}
|
||||||
|
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasMeta ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{chips?.length ? <FilterActiveChips chips={chips} /> : null}
|
||||||
|
{shown != null && total != null ? (
|
||||||
|
<FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} />
|
||||||
|
) : resultsSuffix ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{resultsSuffix}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,14 +7,5 @@ interface PageShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PageShell({ children, className }: PageShellProps) {
|
export function PageShell({ children, className }: PageShellProps) {
|
||||||
return (
|
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'@container/main flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:py-6 lg:px-6',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { ScrollArea } from "@cfdm/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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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 "@cfdm/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 }
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useId, useMemo, useState } from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||||
|
|
||||||
|
const PROJECT_COLOR_PRESETS = [
|
||||||
|
'#3b82f6',
|
||||||
|
'#6366f1',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#ec4899',
|
||||||
|
'#ef4444',
|
||||||
|
'#f97316',
|
||||||
|
'#eab308',
|
||||||
|
'#22c55e',
|
||||||
|
'#14b8a6',
|
||||||
|
'#06b6d4',
|
||||||
|
'#64748b',
|
||||||
|
'#18181b',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const colorPickerGroupVariants = cva(
|
||||||
|
'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'h-7 text-sm',
|
||||||
|
default: 'h-8 text-sm',
|
||||||
|
lg: 'h-9 text-sm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const colorPickerSwatchVariants = cva(
|
||||||
|
'flex shrink-0 cursor-pointer items-center justify-center border-e border-input transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'w-9',
|
||||||
|
default: 'w-10',
|
||||||
|
lg: 'w-11',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const colorPickerInputVariants = cva(
|
||||||
|
'min-w-0 flex-1 border-0 bg-transparent shadow-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'h-7 rounded-e-lg px-2',
|
||||||
|
default: 'h-8 rounded-e-lg px-2.5',
|
||||||
|
lg: 'h-9 rounded-e-lg px-2.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function isValidHex(value: string): boolean {
|
||||||
|
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHex(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
||||||
|
if (/^#[0-9A-Fa-f]{3}$/.test(withHash)) {
|
||||||
|
const [, r, g, b] = withHash
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
|
||||||
|
}
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) {
|
||||||
|
return withHash.toLowerCase()
|
||||||
|
}
|
||||||
|
return withHash
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPickerValue(value: string): string {
|
||||||
|
const normalized = normalizeHex(value)
|
||||||
|
return isValidHex(normalized) ? normalized : '#3b82f6'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorPickerProps extends VariantProps<typeof colorPickerGroupVariants> {
|
||||||
|
id?: string
|
||||||
|
value?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
onBlur?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
'aria-invalid'?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorPicker({
|
||||||
|
id,
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
disabled,
|
||||||
|
placeholder = '#3b82f6',
|
||||||
|
size = 'default',
|
||||||
|
className,
|
||||||
|
'aria-invalid': ariaInvalid,
|
||||||
|
}: ColorPickerProps) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const inputId = id ?? generatedId
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const sizeValue = size ?? 'default'
|
||||||
|
|
||||||
|
const displayValue = value ?? ''
|
||||||
|
const swatchColor = useMemo(() => {
|
||||||
|
const normalized = normalizeHex(displayValue)
|
||||||
|
return isValidHex(normalized) ? normalized : undefined
|
||||||
|
}, [displayValue])
|
||||||
|
|
||||||
|
const handleHexChange = (next: string) => {
|
||||||
|
onChange?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHexBlur = () => {
|
||||||
|
const normalized = normalizeHex(displayValue)
|
||||||
|
if (normalized !== displayValue) {
|
||||||
|
onChange?.(normalized)
|
||||||
|
}
|
||||||
|
onBlur?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyColor = (next: string) => {
|
||||||
|
onChange?.(normalizeHex(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(colorPickerGroupVariants({ size: sizeValue }), className)}
|
||||||
|
data-slot="color-picker"
|
||||||
|
data-size={sizeValue}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
|
>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
disabled={disabled}
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(colorPickerSwatchVariants({ size: sizeValue }))}
|
||||||
|
aria-label="Выбрать цвет"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-4 rounded-sm ring-1 ring-foreground/10',
|
||||||
|
!swatchColor && 'bg-muted',
|
||||||
|
)}
|
||||||
|
style={swatchColor ? { backgroundColor: swatchColor } : undefined}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent align="start" className="w-64 gap-3 p-3">
|
||||||
|
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||||
|
Палитра
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={toPickerValue(displayValue)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-28 w-full cursor-pointer rounded-md border border-input bg-transparent p-1"
|
||||||
|
onChange={(e) => applyColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Быстрый выбор</span>
|
||||||
|
<div className="grid grid-cols-6 gap-1.5">
|
||||||
|
{PROJECT_COLOR_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
aria-label={preset}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square rounded-md ring-1 ring-foreground/10 transition-transform hover:scale-105',
|
||||||
|
swatchColor === preset && 'ring-2 ring-ring',
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: preset }}
|
||||||
|
onClick={() => {
|
||||||
|
applyColor(preset)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
value={displayValue}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-invalid={ariaInvalid}
|
||||||
|
className={colorPickerInputVariants({ size: sizeValue })}
|
||||||
|
style={swatchColor ? { color: swatchColor } : undefined}
|
||||||
|
onChange={(e) => handleHexChange(e.target.value)}
|
||||||
|
onBlur={handleHexBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ColorPicker, normalizeHex, isValidHex, PROJECT_COLOR_PRESETS }
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import { Input } from "@cfdm/ui/components/input"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@cfdm/ui/components/popover"
|
||||||
|
import { Separator } from "@cfdm/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 }
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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 "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@cfdm/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 }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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 "@cfdm/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 }
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { ReactNode } from "react"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/ui/components/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@cfdm/ui/components/select"
|
||||||
|
import { Skeleton } from "@cfdm/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 }
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
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 "@cfdm/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 }
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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 "@cfdm/ui/lib/utils"
|
||||||
|
import { Button } from "@cfdm/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 }
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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 "@cfdm/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 }
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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 "@cfdm/ui/lib/utils"
|
||||||
|
import { Spinner } from "@cfdm/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: _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
@@ -0,0 +1,270 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
ColumnFiltersState,
|
||||||
|
RowData,
|
||||||
|
SortingState,
|
||||||
|
Table,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/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<
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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
@@ -0,0 +1,258 @@
|
|||||||
|
import { createContext, type ReactNode, useContext, useId } from "react"
|
||||||
|
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Label } from "@cfdm/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,53 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
|
||||||
|
|
||||||
export function SectionCardsSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<Card key={index} className="gap-0">
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter>
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
|
||||||
<Card key={index}>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
<Skeleton className="h-4 w-64" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
<Skeleton className="h-4 w-64" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<Skeleton key={index} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,147 +1,95 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
import {
|
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||||
FolderTreeIcon,
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
GlobeIcon,
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
ServerIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
|
||||||
AppCard,
|
|
||||||
AppCardAction,
|
|
||||||
AppCardContent,
|
|
||||||
AppCardDescription,
|
|
||||||
AppCardFooter,
|
|
||||||
AppCardHeader,
|
|
||||||
AppCardTitle,
|
|
||||||
} from '@/components/app-card'
|
|
||||||
import {
|
|
||||||
AppItem,
|
|
||||||
AppItemContent,
|
|
||||||
AppItemGroup,
|
|
||||||
AppItemTitle,
|
|
||||||
} from '@/components/app-item'
|
|
||||||
|
|
||||||
interface SectionCardsProps {
|
export interface SectionCardItem {
|
||||||
domainCount: number
|
label: ReactNode
|
||||||
certCount: number
|
value: string | number | ReactElement
|
||||||
groupCount: number
|
hint?: ReactNode
|
||||||
serviceCount: number
|
icon?: ReactNode
|
||||||
certSummary?: [string, number][]
|
badge?: ReactNode
|
||||||
|
variant?: 'default' | 'warning' | 'destructive'
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionCards({
|
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
domainCount,
|
default: '',
|
||||||
certCount,
|
warning: 'border-warning/50',
|
||||||
groupCount,
|
destructive: 'border-destructive/50',
|
||||||
serviceCount,
|
}
|
||||||
certSummary,
|
|
||||||
}: SectionCardsProps) {
|
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'
|
||||||
|
return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
{items.map((item, idx) => {
|
||||||
<AppCard>
|
const clickable = Boolean(item.onClick)
|
||||||
<AppCardHeader>
|
const content = (
|
||||||
<AppCardDescription>Домены</AppCardDescription>
|
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||||
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
{item.icon ? (
|
||||||
{domainCount}
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||||
</AppCardTitle>
|
{item.icon}
|
||||||
<AppCardAction>
|
</span>
|
||||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
) : null}
|
||||||
</AppCardAction>
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
</AppCardHeader>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<AppCardFooter>
|
{typeof item.label === 'string' ? (
|
||||||
<AppButton
|
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
||||||
variant="link"
|
) : (
|
||||||
className="h-auto p-0"
|
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||||
render={<Link to="/domains" />}
|
)}
|
||||||
>
|
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||||
Управление доменами
|
</div>
|
||||||
</AppButton>
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
</AppCardFooter>
|
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
|
||||||
</AppCard>
|
{item.hint ? (
|
||||||
<AppCard>
|
typeof item.hint === 'string' ? (
|
||||||
<AppCardHeader>
|
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||||
<AppCardDescription>Группы доменов</AppCardDescription>
|
) : (
|
||||||
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||||
{groupCount}
|
)
|
||||||
</AppCardTitle>
|
) : null}
|
||||||
<AppCardAction>
|
</div>
|
||||||
<FolderTreeIcon className="size-4 text-muted-foreground" />
|
</div>
|
||||||
</AppCardAction>
|
</CardContent>
|
||||||
</AppCardHeader>
|
)
|
||||||
<AppCardFooter>
|
return (
|
||||||
<AppButton
|
<Card
|
||||||
variant="link"
|
key={typeof item.label === 'string' ? item.label : idx}
|
||||||
className="h-auto p-0"
|
className={cn(
|
||||||
render={<Link to="/groups" />}
|
'gap-0',
|
||||||
>
|
VARIANT_CLASS[item.variant ?? 'default'],
|
||||||
Канбан групп
|
item.active && 'border-primary ring-1 ring-primary/30',
|
||||||
</AppButton>
|
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||||
</AppCardFooter>
|
)}
|
||||||
</AppCard>
|
onClick={item.onClick}
|
||||||
<AppCard>
|
role={clickable ? 'button' : undefined}
|
||||||
<AppCardHeader>
|
tabIndex={clickable ? 0 : undefined}
|
||||||
<AppCardDescription>Сервисы</AppCardDescription>
|
onKeyDown={
|
||||||
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
clickable
|
||||||
{serviceCount}
|
? (e) => {
|
||||||
</AppCardTitle>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<AppCardAction>
|
e.preventDefault()
|
||||||
<ServerIcon className="size-4 text-muted-foreground" />
|
item.onClick?.()
|
||||||
</AppCardAction>
|
}
|
||||||
</AppCardHeader>
|
}
|
||||||
<AppCardFooter>
|
: undefined
|
||||||
<AppButton
|
}
|
||||||
variant="link"
|
>
|
||||||
className="h-auto p-0"
|
{content}
|
||||||
render={<Link to="/services" search={{ domainId: undefined }} />}
|
</Card>
|
||||||
>
|
)
|
||||||
Управление сервисами
|
})}
|
||||||
</AppButton>
|
|
||||||
</AppCardFooter>
|
|
||||||
</AppCard>
|
|
||||||
<AppCard>
|
|
||||||
<AppCardHeader>
|
|
||||||
<AppCardDescription>Сертификаты</AppCardDescription>
|
|
||||||
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
|
||||||
{certCount}
|
|
||||||
</AppCardTitle>
|
|
||||||
<AppCardAction>
|
|
||||||
<ShieldCheckIcon className="size-4 text-muted-foreground" />
|
|
||||||
</AppCardAction>
|
|
||||||
</AppCardHeader>
|
|
||||||
<AppCardFooter>
|
|
||||||
<AppButton
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0"
|
|
||||||
render={<Link to="/certificates" />}
|
|
||||||
>
|
|
||||||
Мониторинг сертификатов
|
|
||||||
</AppButton>
|
|
||||||
</AppCardFooter>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
{certSummary && certSummary.length > 0 ? (
|
|
||||||
<AppCard>
|
|
||||||
<AppCardHeader>
|
|
||||||
<AppCardTitle className="text-base">Статусы сертификатов</AppCardTitle>
|
|
||||||
<AppCardDescription>Сводка по последней проверке</AppCardDescription>
|
|
||||||
</AppCardHeader>
|
|
||||||
<AppCardContent>
|
|
||||||
<AppItemGroup className="gap-2">
|
|
||||||
{certSummary.map(([status, count]) => (
|
|
||||||
<AppItem key={status} variant="outline" size="sm">
|
|
||||||
<AppItemContent className="flex flex-row items-center justify-between gap-2">
|
|
||||||
<StatusBadge status={status} />
|
|
||||||
<AppItemTitle className="font-medium tabular-nums">{count}</AppItemTitle>
|
|
||||||
</AppItemContent>
|
|
||||||
</AppItem>
|
|
||||||
))}
|
|
||||||
</AppItemGroup>
|
|
||||||
</AppCardContent>
|
|
||||||
</AppCard>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { SectionCards } from './section-cards'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
import { Card, CardContent } from '@cfdm/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,22 +1,22 @@
|
|||||||
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
|
import type { ComponentProps } from 'react'
|
||||||
import type { VariantProps } from 'class-variance-authority'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
const statusVariants: Record<string, BadgeVariant> = {
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
active: 'success',
|
active: 'success',
|
||||||
synced: 'success',
|
synced: 'success',
|
||||||
ok: 'success',
|
ok: 'success',
|
||||||
pending_push: 'secondary',
|
pending_push: 'secondary',
|
||||||
warning: 'secondary',
|
warning: 'warning',
|
||||||
conflict: 'destructive',
|
conflict: 'destructive',
|
||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
expired: 'destructive',
|
expired: 'destructive',
|
||||||
unknown: 'outline',
|
unknown: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
active: 'Активен',
|
active: 'Активен',
|
||||||
synced: 'Синхронизировано',
|
synced: 'Синхронизировано',
|
||||||
pending_push: 'Ожидает отправки',
|
pending_push: 'Ожидает отправки',
|
||||||
@@ -28,20 +28,7 @@ const labels: Record<string, string> = {
|
|||||||
unknown: 'Неизвестно',
|
unknown: 'Неизвестно',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
status: string
|
const variant = STATUS_VARIANT[status] ?? 'outline'
|
||||||
className?: string
|
return <Badge variant={variant}>{label ?? STATUS_LABELS[status] ?? status}</Badge>
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
|
||||||
const variant = statusVariants[status] ?? 'outline'
|
|
||||||
return (
|
|
||||||
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
|
||||||
<span
|
|
||||||
className="size-1.5 rounded-full bg-current opacity-70"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{labels[status] ?? status}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { MoreHorizontalIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import {
|
||||||
|
DataGridCard,
|
||||||
|
columnDefFromDataGrid,
|
||||||
|
} from '@/components/data-grid-card'
|
||||||
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||||
|
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||||
|
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
|
||||||
|
interface SubdomainsDataGridProps {
|
||||||
|
domainId: string
|
||||||
|
rows: SubdomainTableRow[]
|
||||||
|
isDeleting?: boolean
|
||||||
|
isToggling?: boolean
|
||||||
|
onEdit: (row: SubdomainTableRow) => void
|
||||||
|
onDelete: (row: SubdomainTableRow) => void
|
||||||
|
onToggleEnabled: (row: SubdomainTableRow) => void
|
||||||
|
toolbar?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubdomainsDataGrid({
|
||||||
|
domainId,
|
||||||
|
rows,
|
||||||
|
isDeleting = false,
|
||||||
|
isToggling = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleEnabled,
|
||||||
|
toolbar,
|
||||||
|
}: SubdomainsDataGridProps) {
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return rows
|
||||||
|
return rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.subdomain.fqdn.toLowerCase().includes(query) ||
|
||||||
|
formatSubdomainServiceLinks(row.serviceLinks).toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<SubdomainTableRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'fqdn',
|
||||||
|
header: 'Поддомен',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.subdomain.fqdn,
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="truncate font-mono">{row.subdomain.fqdn}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
cell: (row) => (
|
||||||
|
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
||||||
|
{row.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'service',
|
||||||
|
header: 'Группа / Сервис',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => formatSubdomainServiceLinks(row.serviceLinks),
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatSubdomainServiceLinks(row.serviceLinks)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
header: 'Создан',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.subdomain.created_at,
|
||||||
|
className: 'tabular-nums text-muted-foreground',
|
||||||
|
cell: (row) => formatDate(row.subdomain.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableHiding: false,
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
<span className="sr-only">Действия</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(row)}>Редактировать</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={isToggling}
|
||||||
|
onClick={() => onToggleEnabled(row)}
|
||||||
|
>
|
||||||
|
{row.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/domains/$domainId/dns"
|
||||||
|
params={{ domainId }}
|
||||||
|
search={{ host: row.subdomain.fqdn }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
DNS-записи
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[domainId, isToggling, onEdit, onToggleEnabled],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataGridCard
|
||||||
|
title="Поддомены"
|
||||||
|
description="Управление поддоменами и привязками сервисов"
|
||||||
|
columns={columnDefFromDataGrid(columns)}
|
||||||
|
data={filteredRows}
|
||||||
|
rowId={(row) => String(row.subdomain.id)}
|
||||||
|
emptyTitle="Поддомены не найдены"
|
||||||
|
emptyDescription="Создайте поддомен или измените запрос"
|
||||||
|
pinLastColumn
|
||||||
|
actions={
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<ListFiltersBar
|
||||||
|
search={{
|
||||||
|
value: search,
|
||||||
|
onChange: setSearch,
|
||||||
|
placeholder: 'Поиск по поддомену…',
|
||||||
|
}}
|
||||||
|
shown={filteredRows.length}
|
||||||
|
total={rows.length}
|
||||||
|
showReset={Boolean(search.trim())}
|
||||||
|
onReset={() => setSearch('')}
|
||||||
|
/>
|
||||||
|
{toolbar}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleteTarget(null)
|
||||||
|
}}
|
||||||
|
title="Удалить поддомен?"
|
||||||
|
description={
|
||||||
|
deleteTarget
|
||||||
|
? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
onDelete(deleteTarget)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import { MoreHorizontalIcon } from 'lucide-react'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { DataTableView } from '@/components/data-table-view'
|
|
||||||
import type { Density } from '@/components/data-table-density'
|
|
||||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
|
||||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
|
||||||
import { formatDate } from '@/lib/format'
|
|
||||||
import { AppBadge } from '@/components/app-badge'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
|
||||||
|
|
||||||
interface SubdomainsTableProps {
|
|
||||||
domainId: string
|
|
||||||
rows: SubdomainTableRow[]
|
|
||||||
isDeleting?: boolean
|
|
||||||
isToggling?: boolean
|
|
||||||
onEdit: (row: SubdomainTableRow) => void
|
|
||||||
onDelete: (row: SubdomainTableRow) => void
|
|
||||||
onToggleEnabled: (row: SubdomainTableRow) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SubdomainsTable({
|
|
||||||
domainId,
|
|
||||||
rows,
|
|
||||||
isDeleting = false,
|
|
||||||
isToggling = false,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onToggleEnabled,
|
|
||||||
}: SubdomainsTableProps) {
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
|
||||||
const [globalFilter, setGlobalFilter] = useState('')
|
|
||||||
const [density, setDensity] = useState<Density>('comfortable')
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<SubdomainTableRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'fqdn',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<AppButton
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
||||||
>
|
|
||||||
Поддомен
|
|
||||||
</AppButton>
|
|
||||||
),
|
|
||||||
accessorFn: (row) => row.subdomain.fqdn,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono truncate">{row.original.subdomain.fqdn}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
header: 'Статус',
|
|
||||||
enableHiding: true,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<AppBadge variant={row.original.subdomain.enabled ? 'default' : 'outline'}>
|
|
||||||
{row.original.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
|
||||||
</AppBadge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'service',
|
|
||||||
header: 'Группа / Сервис',
|
|
||||||
accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{formatSubdomainServiceLinks(row.original.serviceLinks)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'created_at',
|
|
||||||
header: 'Создан',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
{formatDate(row.original.subdomain.created_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<AppButton variant="outline" size="icon" className="size-8" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
<span className="sr-only">Действия</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
|
||||||
Редактировать
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isToggling}
|
|
||||||
onClick={() => onToggleEnabled(row.original)}
|
|
||||||
>
|
|
||||||
{row.original.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
render={
|
|
||||||
<Link
|
|
||||||
to="/domains/$domainId/dns"
|
|
||||||
params={{ domainId }}
|
|
||||||
search={{ host: row.original.subdomain.fqdn }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
DNS-записи
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setDeleteTarget(row.original)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[domainId, isToggling, onEdit, onToggleEnabled],
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: rows,
|
|
||||||
columns,
|
|
||||||
state: { sorting, globalFilter },
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
globalFilterFn: (row, _columnId, filterValue: string) => {
|
|
||||||
const value = filterValue.toLowerCase()
|
|
||||||
return (
|
|
||||||
row.original.subdomain.fqdn.toLowerCase().includes(value) ||
|
|
||||||
formatSubdomainServiceLinks(row.original.serviceLinks)
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(value)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
initialState: { pagination: { pageSize: 10 } },
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTableView
|
|
||||||
table={table}
|
|
||||||
density={density}
|
|
||||||
onDensityChange={setDensity}
|
|
||||||
searchPlaceholder="Поиск по поддомену…"
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
emptyTitle="Поддомены не найдены"
|
|
||||||
emptyDescription="Создайте поддомен или измените запрос"
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
open={deleteTarget !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDeleteTarget(null)
|
|
||||||
}}
|
|
||||||
title="Удалить поддомен?"
|
|
||||||
description={
|
|
||||||
deleteTarget
|
|
||||||
? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
onConfirm={() => {
|
|
||||||
if (deleteTarget) {
|
|
||||||
onDelete(deleteTarget)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDeleting}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
|
||||||
|
|
||||||
interface TableSkeletonProps {
|
|
||||||
rows?: number
|
|
||||||
columns?: number
|
|
||||||
withCard?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function TableRowsSkeleton({ rows, columns }: { rows: number; columns: number }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<div className="flex gap-4 border-b px-4 py-3">
|
|
||||||
{Array.from({ length: columns }).map((_, index) => (
|
|
||||||
<Skeleton key={index} className="h-4 flex-1" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
||||||
<div key={rowIndex} className="flex gap-4 border-b px-4 py-3 last:border-0">
|
|
||||||
{Array.from({ length: columns }).map((__, colIndex) => (
|
|
||||||
<Skeleton key={colIndex} className="h-4 flex-1" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableSkeleton({
|
|
||||||
rows = 5,
|
|
||||||
columns = 4,
|
|
||||||
withCard = true,
|
|
||||||
}: TableSkeletonProps) {
|
|
||||||
const table = <TableRowsSkeleton rows={rows} columns={columns} />
|
|
||||||
|
|
||||||
if (!withCard) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-lg border">{table}</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-40" />
|
|
||||||
<Skeleton className="h-4 w-56" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">{table}</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
children,
|
return (
|
||||||
...props
|
<NextThemesProvider
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
attribute="class"
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,10 +24,10 @@ declare module '@tanstack/react-router' {
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<RouterProvider router={router} context={{ queryClient }} />
|
<RouterProvider router={router} context={{ queryClient }} />
|
||||||
<Toaster />
|
<Toaster richColors position="top-right" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
type SortingState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||||
import { ShieldCheckIcon } from 'lucide-react'
|
import { GlobeIcon, ShieldCheckIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
@@ -22,11 +13,13 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import {
|
||||||
import { DataTableView } from '@/components/data-table-view'
|
DataGridCard,
|
||||||
import type { Density } from '@/components/data-table-density'
|
columnDefFromDataGrid,
|
||||||
|
} from '@/components/data-grid-card'
|
||||||
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||||
import { ChartCard } from '@/components/chart-card'
|
import { ChartCard } from '@/components/chart-card'
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
import {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppItemContent,
|
AppItemContent,
|
||||||
@@ -40,7 +33,7 @@ import {
|
|||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@cfdm/ui/components/chart'
|
} from '@cfdm/ui/components/chart'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
count: {
|
count: {
|
||||||
@@ -60,8 +53,6 @@ export const Route = createFileRoute('/_auth/certificates')({
|
|||||||
|
|
||||||
function CertificatesPage() {
|
function CertificatesPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'expires_at', desc: false }])
|
|
||||||
const [density, setDensity] = useState<Density>('comfortable')
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {
|
const {
|
||||||
data: certs,
|
data: certs,
|
||||||
@@ -97,82 +88,52 @@ function CertificatesPage() {
|
|||||||
)
|
)
|
||||||
}, [certs, searchQuery])
|
}, [certs, searchQuery])
|
||||||
|
|
||||||
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
const columns = useMemo<DataGridColumn<Certificate>[]>(
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Certificate>[]>(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'hostname',
|
key: 'hostname',
|
||||||
header: ({ column }) => (
|
header: 'Хост',
|
||||||
<AppButton
|
icon: GlobeIcon,
|
||||||
variant="ghost"
|
sortable: true,
|
||||||
className="-ml-3 h-8"
|
sortValue: (row) => row.hostname,
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
cell: (row) => <span className="truncate font-medium">{row.hostname}</span>,
|
||||||
>
|
|
||||||
Хост
|
|
||||||
</AppButton>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
key: 'status',
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
cell: (row) => <StatusBadge status={row.status} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'expires_at',
|
key: 'expires_at',
|
||||||
header: 'Истекает',
|
header: 'Истекает',
|
||||||
cell: ({ row }) => (
|
sortable: true,
|
||||||
<span className="tabular-nums text-muted-foreground">
|
sortValue: (row) => row.expires_at ?? '',
|
||||||
{formatDate(row.original.expires_at)}
|
className: 'tabular-nums text-muted-foreground',
|
||||||
</span>
|
cell: (row) => formatDate(row.expires_at),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'relative',
|
key: 'relative',
|
||||||
header: 'Срок',
|
header: 'Срок',
|
||||||
cell: ({ row }) => (
|
enableHiding: true,
|
||||||
|
cell: (row) => (
|
||||||
<span className="tabular-nums text-muted-foreground">
|
<span className="tabular-nums text-muted-foreground">
|
||||||
{formatRelative(row.original.expires_at)}
|
{formatRelative(row.expires_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
enableHiding: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_checked_at',
|
key: 'last_checked_at',
|
||||||
header: 'Проверка',
|
header: 'Проверка',
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
{formatDate(row.original.last_checked_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.last_checked_at ?? '',
|
||||||
|
className: 'tabular-nums text-muted-foreground',
|
||||||
|
cell: (row) => formatDate(row.last_checked_at),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredCerts,
|
|
||||||
columns,
|
|
||||||
state: { sorting, globalFilter: searchQuery },
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
globalFilterFn: (row, _columnId, value: string) => {
|
|
||||||
const q = value.toLowerCase()
|
|
||||||
return (
|
|
||||||
row.original.hostname.toLowerCase().includes(q) ||
|
|
||||||
row.original.status.toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
initialState: { pagination: { pageSize: 10 } },
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -193,7 +154,7 @@ function CertificatesPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
skeleton={<TableSkeleton rows={5} cols={4} />}
|
||||||
>
|
>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Обзор статусов"
|
title="Обзор статусов"
|
||||||
@@ -236,26 +197,30 @@ function CertificatesPage() {
|
|||||||
</AppItemGroup>
|
</AppItemGroup>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DataTableCard
|
<DataGridCard
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Хосты с активными сервисами или ручным мониторингом"
|
description="Хосты с активными сервисами или ручным мониторингом"
|
||||||
isEmpty={false}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
>
|
data={filteredCerts}
|
||||||
<DataTableView
|
rowId={(row) => String(row.id)}
|
||||||
table={table}
|
emptyTitle="Сертификаты не найдены"
|
||||||
density={density}
|
emptyDescription="Сертификаты появятся после проверки доменов"
|
||||||
onDensityChange={setDensity}
|
initialSorting={[{ id: 'expires_at', desc: false }]}
|
||||||
searchPlaceholder="Поиск по хосту или статусу…"
|
enableColumnVisibility
|
||||||
searchValue={searchQuery}
|
actions={
|
||||||
onSearchChange={setSearchQuery}
|
<ListFiltersBar
|
||||||
emptyTitle={isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'}
|
search={{
|
||||||
emptyDescription={
|
value: searchQuery,
|
||||||
isFilteredEmpty
|
onChange: setSearchQuery,
|
||||||
? 'Измените поисковый запрос'
|
placeholder: 'Поиск по хосту или статусу…',
|
||||||
: 'Сертификаты появятся после проверки доменов'
|
}}
|
||||||
}
|
shown={filteredCerts.length}
|
||||||
/>
|
total={certs?.length ?? 0}
|
||||||
</DataTableCard>
|
showReset={Boolean(searchQuery.trim())}
|
||||||
|
onReset={() => setSearchQuery('')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { NetworkIcon } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||||
|
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DnsRecordsDataGrid } from '@/components/dns-records-data-grid'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { TableToolbar } from '@/components/table-toolbar'
|
|
||||||
import { DnsRecordsTable } from '@/components/dns-records-table'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
import { AppButton } from '@/components/app-button'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import { AppField, AppFieldGroup, AppFieldLabel } from '@/components/app-field'
|
import { AppField, AppFieldGroup, AppFieldLabel } from '@/components/app-field'
|
||||||
@@ -144,17 +141,12 @@ function DnsPage() {
|
|||||||
)
|
)
|
||||||
}, [records, host, searchQuery])
|
}, [records, host, searchQuery])
|
||||||
|
|
||||||
const hasRecords = filteredRecords.length > 0
|
|
||||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||||
description={
|
description={
|
||||||
host
|
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
|
||||||
? `Фильтр по хосту: ${host}`
|
|
||||||
: 'Управление DNS-записями зоны'
|
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -180,50 +172,16 @@ function DnsPage() {
|
|||||||
void refetchDomain()
|
void refetchDomain()
|
||||||
void refetchRecords()
|
void refetchRecords()
|
||||||
}}
|
}}
|
||||||
skeleton={<TableSkeleton rows={6} columns={6} />}
|
skeleton={<TableSkeleton rows={6} cols={6} />}
|
||||||
>
|
>
|
||||||
<DataTableCard
|
<DnsRecordsDataGrid
|
||||||
title="DNS-записи"
|
records={filteredRecords}
|
||||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||||
emptyTitle={
|
isDeleting={deleteMutation.isPending}
|
||||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
search={host ?? searchQuery}
|
||||||
}
|
onSearchChange={setSearchQuery}
|
||||||
emptyDescription={
|
hideSearch={Boolean(host)}
|
||||||
isFilteredEmpty
|
/>
|
||||||
? 'Измените поисковый запрос'
|
|
||||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
|
||||||
}
|
|
||||||
emptyIcon={NetworkIcon}
|
|
||||||
isEmpty={!hasRecords}
|
|
||||||
emptyAction={
|
|
||||||
isFilteredEmpty ? (
|
|
||||||
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
|
|
||||||
Сбросить фильтр
|
|
||||||
</AppButton>
|
|
||||||
) : (
|
|
||||||
<AppButton onClick={() => setSheetOpen(true)}>Новая запись</AppButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
toolbar={
|
|
||||||
!host ? (
|
|
||||||
<TableToolbar
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={setSearchQuery}
|
|
||||||
placeholder="Поиск по имени или значению…"
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{hasRecords && (
|
|
||||||
<div className="p-4">
|
|
||||||
<DnsRecordsTable
|
|
||||||
records={filteredRecords}
|
|
||||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DataTableCard>
|
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
|
||||||
<FormSheet
|
<FormSheet
|
||||||
@@ -259,9 +217,9 @@ function DnsPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{dnsTypeItems.map((item) => (
|
{DNS_TYPES.map((type) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<SelectItem key={type} value={type}>
|
||||||
{item.label}
|
{type}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -269,53 +227,30 @@ function DnsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
<FormFieldSimple label="Имя" htmlFor="name" error={form.formState.errors.name}>
|
||||||
label="Имя"
|
<AppInput id="name" placeholder="@" {...form.register('name')} />
|
||||||
htmlFor="name"
|
|
||||||
error={form.formState.errors.name}
|
|
||||||
>
|
|
||||||
<AppInput
|
|
||||||
id="name"
|
|
||||||
placeholder="@"
|
|
||||||
{...form.register('name')}
|
|
||||||
aria-invalid={!!form.formState.errors.name}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
<FormFieldSimple
|
||||||
label="Значение"
|
label="Значение"
|
||||||
htmlFor="content"
|
htmlFor="content"
|
||||||
error={form.formState.errors.content}
|
error={form.formState.errors.content}
|
||||||
>
|
>
|
||||||
<AppInput
|
<AppInput id="content" {...form.register('content')} />
|
||||||
id="content"
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
{...form.register('content')}
|
|
||||||
aria-invalid={!!form.formState.errors.content}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
<FormFieldSimple label="TTL" htmlFor="ttl" error={form.formState.errors.ttl}>
|
||||||
label="TTL"
|
|
||||||
htmlFor="ttl"
|
|
||||||
error={form.formState.errors.ttl}
|
|
||||||
>
|
|
||||||
<AppInput
|
<AppInput
|
||||||
id="ttl"
|
id="ttl"
|
||||||
type="number"
|
type="number"
|
||||||
{...form.register('ttl', { valueAsNumber: true })}
|
{...form.register('ttl', { valueAsNumber: true })}
|
||||||
aria-invalid={!!form.formState.errors.ttl}
|
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<AppField className="flex flex-row items-center justify-between gap-4">
|
<AppField orientation="horizontal">
|
||||||
<AppFieldLabel htmlFor="proxied">Прокси Cloudflare</AppFieldLabel>
|
<AppFieldLabel htmlFor="proxied">Прокси Cloudflare</AppFieldLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="proxied"
|
name="proxied"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<AppSwitch
|
<AppSwitch id="proxied" checked={field.value} onCheckedChange={field.onChange} />
|
||||||
id="proxied"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { GlobeIcon } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
domainDetailQueryOptions,
|
domainDetailQueryOptions,
|
||||||
domainServiceBindingsQueryOptions,
|
domainServiceBindingsQueryOptions,
|
||||||
@@ -16,8 +15,7 @@ import { QueryState } from '@/components/query-state'
|
|||||||
import { DomainHeader } from '@/components/domain-header'
|
import { DomainHeader } from '@/components/domain-header'
|
||||||
import { DomainActionsBar } from '@/components/domain-actions-bar'
|
import { DomainActionsBar } from '@/components/domain-actions-bar'
|
||||||
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { SubdomainsDataGrid } from '@/components/subdomains-data-grid'
|
||||||
import { SubdomainsTable } from '@/components/subdomains-table'
|
|
||||||
import {
|
import {
|
||||||
SubdomainEditSheet,
|
SubdomainEditSheet,
|
||||||
type SubdomainEditValues,
|
type SubdomainEditValues,
|
||||||
@@ -34,7 +32,7 @@ import {
|
|||||||
AppCardTitle,
|
AppCardTitle,
|
||||||
} from '@/components/app-card'
|
} from '@/components/app-card'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||||
loader: async ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
@@ -54,12 +52,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
|||||||
function DomainPageSkeleton() {
|
function DomainPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<TableSkeleton withCard={false} rows={1} columns={2} />
|
<TableSkeleton rows={1} cols={2} />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<TableSkeleton withCard rows={3} columns={2} />
|
<TableSkeleton rows={3} cols={2} />
|
||||||
<TableSkeleton withCard rows={3} columns={2} />
|
<TableSkeleton rows={3} cols={2} />
|
||||||
</div>
|
</div>
|
||||||
<TableSkeleton rows={5} columns={5} />
|
<TableSkeleton rows={5} cols={5} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -252,44 +250,23 @@ function DomainOverviewPage() {
|
|||||||
<DomainBindingsCard bindings={bindings} />
|
<DomainBindingsCard bindings={bindings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTableCard
|
<SubdomainsDataGrid
|
||||||
title="Поддомены"
|
domainId={domainId}
|
||||||
description="Управление поддоменами и привязками сервисов"
|
rows={subdomainRows}
|
||||||
isEmpty={subdomainRows.length === 0}
|
isDeleting={deleteSubdomainMutation.isPending}
|
||||||
emptyTitle="Поддомены не найдены"
|
isToggling={updateSubdomainMutation.isPending}
|
||||||
emptyDescription="Создайте поддомен вручную или синхронизируйте зону с Cloudflare"
|
onEdit={openEditSheet}
|
||||||
emptyIcon={GlobeIcon}
|
onDelete={(row) =>
|
||||||
emptyAction={
|
deleteSubdomainMutation.mutate(row.subdomain.id)
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
}
|
||||||
<AppButton onClick={openCreateSheet}>Создать поддомен</AppButton>
|
onToggleEnabled={(row) =>
|
||||||
<AppButton
|
updateSubdomainMutation.mutate({
|
||||||
variant="outline"
|
id: row.subdomain.id,
|
||||||
onClick={() => syncMutation.mutate()}
|
enabled: !row.subdomain.enabled,
|
||||||
disabled={syncMutation.isPending}
|
})
|
||||||
>
|
|
||||||
Синхронизировать
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
|
toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
|
||||||
>
|
/>
|
||||||
<SubdomainsTable
|
|
||||||
domainId={domainId}
|
|
||||||
rows={subdomainRows}
|
|
||||||
isDeleting={deleteSubdomainMutation.isPending}
|
|
||||||
isToggling={updateSubdomainMutation.isPending}
|
|
||||||
onEdit={openEditSheet}
|
|
||||||
onDelete={(row) =>
|
|
||||||
deleteSubdomainMutation.mutate(row.subdomain.id)
|
|
||||||
}
|
|
||||||
onToggleEnabled={(row) =>
|
|
||||||
updateSubdomainMutation.mutate({
|
|
||||||
id: row.subdomain.id,
|
|
||||||
enabled: !row.subdomain.enabled,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DataTableCard>
|
|
||||||
|
|
||||||
<SubdomainEditSheet
|
<SubdomainEditSheet
|
||||||
mode={sheetMode}
|
mode={sheetMode}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
import { DomainsDataGrid, type DomainTableRow } from '@/components/domains-data-grid'
|
||||||
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
|
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { AppButton } from '@/components/app-button'
|
import { AppButton } from '@/components/app-button'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import { AppFieldGroup } from '@/components/app-field'
|
import { AppFieldGroup } from '@/components/app-field'
|
||||||
@@ -46,6 +46,7 @@ function DomainsPage() {
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [importGroupId, setImportGroupId] = useState('')
|
const [importGroupId, setImportGroupId] = useState('')
|
||||||
const [filterGroupId, setFilterGroupId] = useState('')
|
const [filterGroupId, setFilterGroupId] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const listGroupId =
|
const listGroupId =
|
||||||
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -138,6 +139,14 @@ function DomainsPage() {
|
|||||||
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
|
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
|
||||||
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
|
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
|
||||||
|
|
||||||
|
const emptyAction = isZeroResults ? (
|
||||||
|
<AppButton variant="outline" onClick={() => setFilterGroupId('')}>
|
||||||
|
Сбросить фильтр
|
||||||
|
</AppButton>
|
||||||
|
) : (
|
||||||
|
<AppButton onClick={() => setSheetOpen(true)}>Импортировать домен</AppButton>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -160,40 +169,31 @@ function DomainsPage() {
|
|||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
skeleton={<TableSkeleton />}
|
skeleton={<TableSkeleton />}
|
||||||
>
|
>
|
||||||
<DataTableCard
|
{isTrulyEmpty || isZeroResults ? (
|
||||||
title="Список доменов"
|
<EmptyState
|
||||||
description="Импортированные зоны Cloudflare"
|
icon={GlobeIcon}
|
||||||
isEmpty={isTrulyEmpty || isZeroResults}
|
title={isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'}
|
||||||
emptyTitle={
|
description={
|
||||||
isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'
|
isZeroResults
|
||||||
}
|
? 'По выбранному фильтру группы нет подходящих зон'
|
||||||
emptyDescription={
|
: 'Импортируйте зону из аккаунта Cloudflare'
|
||||||
isZeroResults
|
}
|
||||||
? 'По выбранному фильтру группы нет подходящих зон'
|
action={emptyAction}
|
||||||
: 'Импортируйте зону из аккаунта Cloudflare'
|
/>
|
||||||
}
|
) : (
|
||||||
emptyIcon={GlobeIcon}
|
<DomainsDataGrid
|
||||||
emptyAction={
|
data={tableData}
|
||||||
isZeroResults ? (
|
totalCount={tableData.length}
|
||||||
<AppButton variant="outline" onClick={() => setFilterGroupId('')}>
|
groupFilterItems={groupItems}
|
||||||
Сбросить фильтр
|
groupFilterValue={filterGroupId || 'all'}
|
||||||
</AppButton>
|
onGroupFilterChange={handleFilterGroupChange}
|
||||||
) : (
|
search={search}
|
||||||
<AppButton onClick={() => setSheetOpen(true)}>Импортировать домен</AppButton>
|
onSearchChange={setSearch}
|
||||||
)
|
onDelete={handleDeleteDomain}
|
||||||
}
|
isDeleting={deleteMutation.isPending}
|
||||||
>
|
emptyAction={emptyAction}
|
||||||
{!isTrulyEmpty && !isZeroResults && (
|
/>
|
||||||
<DomainsDataTable
|
)}
|
||||||
data={tableData}
|
|
||||||
groupFilterItems={groupItems}
|
|
||||||
groupFilterValue={filterGroupId || 'all'}
|
|
||||||
onGroupFilterChange={handleFilterGroupChange}
|
|
||||||
onDelete={handleDeleteDomain}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DataTableCard>
|
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
|
||||||
<FormSheet
|
<FormSheet
|
||||||
|
|||||||
@@ -10,18 +10,16 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
|
||||||
import { TableToolbar } from '@/components/table-toolbar'
|
|
||||||
import { AppButton } from '@/components/app-button'
|
|
||||||
import {
|
import {
|
||||||
Table,
|
DataGridCard,
|
||||||
TableBody,
|
columnDefFromDataGrid,
|
||||||
TableCell,
|
} from '@/components/data-grid-card'
|
||||||
TableHead,
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
TableHeader,
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||||
TableRow,
|
import { EmptyState } from '@/components/empty-state'
|
||||||
} from '@cfdm/ui/components/table'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import type { DomainListItem } from '@/lib/schemas'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
||||||
loader: async ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
@@ -64,7 +62,71 @@ function GroupDetailPage() {
|
|||||||
}, [domains, searchQuery])
|
}, [domains, searchQuery])
|
||||||
|
|
||||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||||
const hasRecords = filteredDomains.length > 0
|
const isTrulyEmpty = filteredDomains.length === 0 && !searchQuery.trim()
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<DomainListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'zone_name',
|
||||||
|
header: 'Зона',
|
||||||
|
icon: GlobeIcon,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.zone_name,
|
||||||
|
cell: (row) => <span className="font-medium">{row.zone_name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
cell: (row) => <StatusBadge status={row.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'service_count',
|
||||||
|
header: 'Сервисы',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.service_count,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 tabular-nums"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link to="/services" search={{ domainId: row.id }} />}
|
||||||
|
>
|
||||||
|
{row.service_count}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableHiding: false,
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Обзор
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const emptyAction = isFilteredEmpty ? (
|
||||||
|
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||||
|
Сбросить фильтр
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||||
|
Открыть канбан групп
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -76,13 +138,9 @@ function GroupDetailPage() {
|
|||||||
: 'Домены в группе'
|
: 'Домены в группе'
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<AppButton
|
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||||
variant="outline"
|
|
||||||
nativeButton={false}
|
|
||||||
render={<Link to="/groups" />}
|
|
||||||
>
|
|
||||||
К группам
|
К группам
|
||||||
</AppButton>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<QueryState
|
<QueryState
|
||||||
@@ -93,97 +151,43 @@ function GroupDetailPage() {
|
|||||||
void refetchGroup()
|
void refetchGroup()
|
||||||
void refetchDomains()
|
void refetchDomains()
|
||||||
}}
|
}}
|
||||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
skeleton={<TableSkeleton rows={5} cols={4} />}
|
||||||
>
|
>
|
||||||
<DataTableCard
|
{isTrulyEmpty || isFilteredEmpty ? (
|
||||||
title="Домены группы"
|
<EmptyState
|
||||||
description="Список доменов, назначенных этой группе"
|
icon={GlobeIcon}
|
||||||
emptyTitle={
|
title={isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'}
|
||||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
description={
|
||||||
}
|
isFilteredEmpty
|
||||||
emptyDescription={
|
? 'Измените поисковый запрос'
|
||||||
isFilteredEmpty
|
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||||
? 'Измените поисковый запрос'
|
}
|
||||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
action={emptyAction}
|
||||||
}
|
/>
|
||||||
emptyIcon={GlobeIcon}
|
) : (
|
||||||
isEmpty={!hasRecords}
|
<DataGridCard
|
||||||
emptyAction={
|
title="Домены группы"
|
||||||
isFilteredEmpty ? (
|
description="Список доменов, назначенных этой группе"
|
||||||
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
|
columns={columnDefFromDataGrid(columns)}
|
||||||
Сбросить фильтр
|
data={filteredDomains}
|
||||||
</AppButton>
|
rowId={(row) => String(row.id)}
|
||||||
) : (
|
emptyTitle="В группе нет доменов"
|
||||||
<AppButton
|
pinLastColumn
|
||||||
variant="outline"
|
actions={
|
||||||
nativeButton={false}
|
<ListFiltersBar
|
||||||
render={<Link to="/groups" />}
|
search={{
|
||||||
>
|
value: searchQuery,
|
||||||
Открыть канбан групп
|
onChange: setSearchQuery,
|
||||||
</AppButton>
|
placeholder: 'Поиск по зоне…',
|
||||||
)
|
}}
|
||||||
}
|
shown={filteredDomains.length}
|
||||||
toolbar={
|
total={domains?.length ?? 0}
|
||||||
<TableToolbar
|
showReset={Boolean(searchQuery.trim())}
|
||||||
value={searchQuery}
|
onReset={() => setSearchQuery('')}
|
||||||
onChange={setSearchQuery}
|
/>
|
||||||
placeholder="Поиск по зоне…"
|
}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
>
|
|
||||||
{hasRecords && (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="h-10">
|
|
||||||
<TableHead>Зона</TableHead>
|
|
||||||
<TableHead>Статус</TableHead>
|
|
||||||
<TableHead>Сервисы</TableHead>
|
|
||||||
<TableHead className="text-right">Действия</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredDomains.map((domain) => (
|
|
||||||
<TableRow key={domain.id} className="h-10 text-sm">
|
|
||||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={domain.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AppButton
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0 tabular-nums"
|
|
||||||
nativeButton={false}
|
|
||||||
render={
|
|
||||||
<Link
|
|
||||||
to="/services"
|
|
||||||
search={{ domainId: domain.id }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{domain.service_count}
|
|
||||||
</AppButton>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<AppButton
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
nativeButton={false}
|
|
||||||
render={
|
|
||||||
<Link
|
|
||||||
to="/domains/$domainId"
|
|
||||||
params={{ domainId: String(domain.id) }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Обзор
|
|
||||||
</AppButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</DataTableCard>
|
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { KpiCard } from '@/components/kpi-card'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import { ChartCard } from '@/components/chart-card'
|
import { ChartCard } from '@/components/chart-card'
|
||||||
import { ExpiringCertsCard } from '@/components/expiring-certs-card'
|
import { ExpiringCertsCard } from '@/components/expiring-certs-card'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -63,15 +63,15 @@ const statusChartConfig = {
|
|||||||
count: { label: 'Сертификаты' },
|
count: { label: 'Сертификаты' },
|
||||||
active: { label: 'Активен', color: 'var(--success)' },
|
active: { label: 'Активен', color: 'var(--success)' },
|
||||||
ok: { label: 'OK', color: 'var(--success)' },
|
ok: { label: 'OK', color: 'var(--success)' },
|
||||||
warning: { label: 'Предупреждение', color: 'var(--chart-3)' },
|
warning: { label: 'Предупреждение', color: 'var(--warning)' },
|
||||||
pending_push: { label: 'Ожидает', color: 'var(--chart-2)' },
|
pending_push: { label: 'Ожидает', color: 'var(--info)' },
|
||||||
expired: { label: 'Истёк', color: 'var(--destructive)' },
|
expired: { label: 'Истёк', color: 'var(--destructive)' },
|
||||||
error: { label: 'Ошибка', color: 'var(--destructive)' },
|
error: { label: 'Ошибка', color: 'var(--destructive)' },
|
||||||
unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' },
|
unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' },
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
const groupChartConfig = {
|
const groupChartConfig = {
|
||||||
count: { label: 'Домены', color: 'var(--chart-2)' },
|
count: { label: 'Домены', color: 'var(--chart-1)' },
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
@@ -135,38 +135,50 @@ function DashboardPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<SectionCards
|
||||||
<KpiCard
|
items={[
|
||||||
label="Домены"
|
{
|
||||||
value={domains?.length ?? 0}
|
label: 'Домены',
|
||||||
icon={GlobeIcon}
|
value: domains?.length ?? 0,
|
||||||
actionLabel="Управление"
|
icon: <GlobeIcon className="size-4" />,
|
||||||
actionRender={<Link to="/domains" />}
|
hint: (
|
||||||
/>
|
<Link to="/domains" className="hover:underline">
|
||||||
<KpiCard
|
Управление
|
||||||
label="Группы доменов"
|
</Link>
|
||||||
value={groups?.length ?? 0}
|
),
|
||||||
icon={FolderTreeIcon}
|
},
|
||||||
actionLabel="Канбан"
|
{
|
||||||
actionRender={<Link to="/groups" />}
|
label: 'Группы доменов',
|
||||||
/>
|
value: groups?.length ?? 0,
|
||||||
<KpiCard
|
icon: <FolderTreeIcon className="size-4" />,
|
||||||
label="Сервисы"
|
hint: (
|
||||||
value={serviceCount}
|
<Link to="/groups" className="hover:underline">
|
||||||
icon={ServerIcon}
|
Канбан
|
||||||
actionLabel="Управление"
|
</Link>
|
||||||
actionRender={
|
),
|
||||||
<Link to="/services" search={{ domainId: undefined }} />
|
},
|
||||||
}
|
{
|
||||||
/>
|
label: 'Сервисы',
|
||||||
<KpiCard
|
value: serviceCount,
|
||||||
label="Сертификаты"
|
icon: <ServerIcon className="size-4" />,
|
||||||
value={certs?.length ?? 0}
|
hint: (
|
||||||
icon={ShieldCheckIcon}
|
<Link to="/services" search={{ domainId: undefined }} className="hover:underline">
|
||||||
actionLabel="Мониторинг"
|
Управление
|
||||||
actionRender={<Link to="/certificates" />}
|
</Link>
|
||||||
/>
|
),
|
||||||
</div>
|
},
|
||||||
|
{
|
||||||
|
label: 'Сертификаты',
|
||||||
|
value: certs?.length ?? 0,
|
||||||
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
hint: (
|
||||||
|
<Link to="/certificates" className="hover:underline">
|
||||||
|
Мониторинг
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": false,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
@@ -27,8 +27,10 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 "@cfdm/ui/lib/utils"
|
||||||
|
import { Separator } from "@cfdm/ui/components/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "button-group-text",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
type Locale,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@cfdm/ui/components/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative rounded-(--cell-radius)",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute inset-0 bg-popover opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"font-medium select-none",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-(--cell-size) select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] text-muted-foreground select-none",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_end
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ function SelectContent({
|
|||||||
children,
|
children,
|
||||||
side = "bottom",
|
side = "bottom",
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
align = "start",
|
align = "center",
|
||||||
alignOffset = 0,
|
alignOffset = 0,
|
||||||
alignItemWithTrigger = false,
|
alignItemWithTrigger = true,
|
||||||
...props
|
...props
|
||||||
}: SelectPrimitive.Popup.Props &
|
}: SelectPrimitive.Popup.Props &
|
||||||
Pick<
|
Pick<
|
||||||
@@ -83,7 +83,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Popup
|
<SelectPrimitive.Popup
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={alignItemWithTrigger}
|
data-align-trigger={alignItemWithTrigger}
|
||||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Loader2Icon } from "lucide-react"
|
|||||||
|
|
||||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
return (
|
return (
|
||||||
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,48 +6,6 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-success: var(--success);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
@@ -65,10 +23,14 @@
|
|||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--success: oklch(0.527 0.154 150.069);
|
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
@@ -77,11 +39,17 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--destructive-foreground: var(--color-red-800);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--success: var(--color-emerald-500);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--success-foreground: var(--color-emerald-900);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--info: var(--color-violet-500);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--info-foreground: var(--color-violet-900);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-900);
|
||||||
|
--invert: var(--color-zinc-900);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
--focus: var(--color-blue-500);
|
||||||
|
--focus-foreground: var(--color-blue-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -100,10 +68,14 @@
|
|||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--success: oklch(0.765 0.177 163.223);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
@@ -112,48 +84,66 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--destructive-foreground: var(--color-red-600);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--success: var(--color-emerald-500);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--success-foreground: var(--color-emerald-600);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--info: var(--color-violet-500);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--info-foreground: var(--color-violet-600);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-600);
|
||||||
|
--invert: var(--color-zinc-700);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
--focus: var(--color-blue-500);
|
||||||
|
--focus-foreground: var(--color-blue-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@theme inline {
|
||||||
:root:not(.light) {
|
--color-background: var(--background);
|
||||||
--background: oklch(0.145 0 0);
|
--color-foreground: var(--foreground);
|
||||||
--foreground: oklch(0.985 0 0);
|
--color-card: var(--card);
|
||||||
--card: oklch(0.205 0 0);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--color-popover: var(--popover);
|
||||||
--popover: oklch(0.205 0 0);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--color-primary: var(--primary);
|
||||||
--primary: oklch(0.922 0 0);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--color-secondary: var(--secondary);
|
||||||
--secondary: oklch(0.269 0 0);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--color-muted: var(--muted);
|
||||||
--muted: oklch(0.269 0 0);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--color-accent: var(--accent);
|
||||||
--accent: oklch(0.269 0 0);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--color-destructive: var(--destructive);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--color-border: var(--border);
|
||||||
--success: oklch(0.765 0.177 163.223);
|
--color-input: var(--input);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--color-ring: var(--ring);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--color-chart-1: var(--chart-1);
|
||||||
--ring: oklch(0.556 0 0);
|
--color-chart-2: var(--chart-2);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--color-chart-3: var(--chart-3);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--color-chart-4: var(--chart-4);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--color-chart-5: var(--chart-5);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--color-sidebar: var(--sidebar);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
}
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-invert-foreground: var(--invert-foreground);
|
||||||
|
--color-invert: var(--invert);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-focus: var(--focus);
|
||||||
|
--color-focus-foreground: var(--focus-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+84
-3
@@ -93,7 +93,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.5.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
'@cfdm/shared':
|
'@cfdm/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
@@ -103,6 +103,9 @@ importers:
|
|||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1([email protected]([email protected]))([email protected])
|
version: 6.3.1([email protected]([email protected]))([email protected])
|
||||||
|
'@dnd-kit/modifiers':
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
|
||||||
'@dnd-kit/sortable':
|
'@dnd-kit/sortable':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
|
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
|
||||||
@@ -124,6 +127,9 @@ importers:
|
|||||||
'@tanstack/react-table':
|
'@tanstack/react-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3([email protected]([email protected]))([email protected])
|
version: 8.21.3([email protected]([email protected]))([email protected])
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.14.4
|
||||||
|
version: 3.14.4([email protected]([email protected]))([email protected])
|
||||||
'@tanstack/router-vite-plugin':
|
'@tanstack/router-vite-plugin':
|
||||||
specifier: ^1.167.18
|
specifier: ^1.167.18
|
||||||
version: 1.167.18(@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 1.167.18(@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -133,6 +139,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.18.0([email protected])
|
version: 1.18.0([email protected])
|
||||||
@@ -142,6 +151,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1(@types/[email protected])([email protected])
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.6
|
specifier: ^19.2.6
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
@@ -188,6 +200,9 @@ importers:
|
|||||||
globals:
|
globals:
|
||||||
specifier: ^17.6.0
|
specifier: ^17.6.0
|
||||||
version: 17.6.0
|
version: 17.6.0
|
||||||
|
tw-animate-css:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.4.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~6.0.2
|
specifier: ~6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
@@ -246,7 +261,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.5.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.16
|
specifier: ^1.1.16
|
||||||
version: 1.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -271,6 +286,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.18.0
|
specifier: ^1.18.0
|
||||||
version: 1.18.0([email protected])
|
version: 1.18.0([email protected])
|
||||||
@@ -280,6 +298,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^10.0.1
|
||||||
|
version: 10.0.1(@types/[email protected])([email protected])
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
@@ -409,6 +430,9 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@date-fns/[email protected]':
|
||||||
|
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||||
|
|
||||||
'@dnd-kit/[email protected]':
|
'@dnd-kit/[email protected]':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -420,6 +444,12 @@ packages:
|
|||||||
react: '>=16.8.0'
|
react: '>=16.8.0'
|
||||||
react-dom: '>=16.8.0'
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@dnd-kit/[email protected]':
|
'@dnd-kit/[email protected]':
|
||||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2013,6 +2043,12 @@ packages:
|
|||||||
react: '>=16.8'
|
react: '>=16.8'
|
||||||
react-dom: '>=16.8'
|
react-dom: '>=16.8'
|
||||||
|
|
||||||
|
'@tanstack/[email protected]':
|
||||||
|
resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@tanstack/[email protected]':
|
'@tanstack/[email protected]':
|
||||||
resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==}
|
resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -2057,6 +2093,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@tanstack/[email protected]':
|
||||||
|
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
|
||||||
|
|
||||||
'@tanstack/[email protected]':
|
'@tanstack/[email protected]':
|
||||||
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -2513,6 +2552,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -3352,6 +3394,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8.0'
|
||||||
|
react: '>=16.8.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4128,7 +4180,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.29.7
|
'@babel/helper-string-parser': 7.29.7
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
|
||||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@base-ui/[email protected](@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
'@base-ui/utils': 0.2.9(@types/[email protected])([email protected]([email protected]))([email protected])
|
'@base-ui/utils': 0.2.9(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -4138,7 +4190,9 @@ snapshots:
|
|||||||
react-dom: 19.2.7([email protected])
|
react-dom: 19.2.7([email protected])
|
||||||
use-sync-external-store: 1.6.0([email protected])
|
use-sync-external-store: 1.6.0([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@date-fns/tz': 1.5.0
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
date-fns: 4.4.0
|
||||||
|
|
||||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4151,6 +4205,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
|
'@date-fns/[email protected]': {}
|
||||||
|
|
||||||
'@dnd-kit/[email protected]([email protected])':
|
'@dnd-kit/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -4164,6 +4220,13 @@ snapshots:
|
|||||||
react-dom: 19.2.7([email protected])
|
react-dom: 19.2.7([email protected])
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
|
||||||
|
'@dnd-kit/utilities': 3.2.2([email protected])
|
||||||
|
react: 19.2.7
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
|
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
|
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
|
||||||
@@ -5351,6 +5414,12 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7([email protected])
|
react-dom: 19.2.7([email protected])
|
||||||
|
|
||||||
|
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.2
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7([email protected])
|
||||||
|
|
||||||
'@tanstack/[email protected]':
|
'@tanstack/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/history': 1.162.0
|
'@tanstack/history': 1.162.0
|
||||||
@@ -5416,6 +5485,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/[email protected]': {}
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
'@tanstack/[email protected]': {}
|
'@tanstack/[email protected]': {}
|
||||||
|
|
||||||
'@turbo/[email protected]':
|
'@turbo/[email protected]':
|
||||||
@@ -5903,6 +5974,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6689,6 +6762,14 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-json-comments: 2.0.1
|
strip-json-comments: 2.0.1
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@date-fns/tz': 1.5.0
|
||||||
|
date-fns: 4.4.0
|
||||||
|
react: 19.2.7
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user