Compare commits

...
10 Commits
Author SHA1 Message Date
DenozordecandCursor 6c379f339b chore(ui): удалить мёртвые App* и shared-обёртки
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m24s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m1s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Удалены неиспользуемые page-header, resource-list, table-*, app-spinner/accordion/checkbox/switch, c-tabs-2 и ui/svgs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:56:45 +07:00
DenozordecandCursor c6fa3428e6 feat(layout): подключить chrome app-shell-12 в AppShell
SearchMenu (Cmd+K) и AppsMenu из конфига App Switcher в SiteHeader; sidebar без изменений.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:55:21 +07:00
DenozordecandCursor a4fd955c1b refactor(forms): перевести service sheets на FormSheet и RHF
SubdomainEditSheet на FormSheet+RHF+Zod; FormField hint через FieldDescription; service-edit на Field/Item примитивы.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:53:54 +07:00
DenozordecandCursor 52edc3ba73 refactor(ui): выровнять lists и domain detail под ResourcePage
Bulk toolbar на Frame/muted; availability на Field+Item; domain detail на CountedLineTabs; group detail с DetailPanel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:52:20 +07:00
DenozordecandCursor 57ee231417 refactor(ui): заменить Badge и Alert на ReUI semantic
Перевести AppBadge/AppAlert и потребители на @/components/reui; EmptyState — IconStack в духе empty-state-12.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:48:42 +07:00
DenozordecandCursor c3ad676e75 refactor(ui): перевести bindings и login на Frame / auth-13
Заменить AppCard на Frame в domain/service bindings и login-form; TableSkeleton — Frame + Skeleton.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:47:31 +07:00
DenozordecandCursor a15b52df95 feat(settings): адаптировать integrations под ReUI settings-16
Item-rows с status Badge и Collapsible-формами для App Switcher и VPS Tracker; SettingsShell — spacing/header в духе settings-9.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:46:28 +07:00
DenozordecandCursor 1e8236298d feat(ui): добавить KPI card-35 на domains, groups и services
ReUI PRO card-35 через KpiStatGrid; клик по KPI синхронизирует tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:44:06 +07:00
DenozordecandCursor af191dcd5b fix(ui): убрать FieldSeparator под раскрытым Health-check
SettingRow last всегда true для Health-check — separator не между Switch и nested fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:41:25 +07:00
DenozordecandCursor b4daa76696 chore(rules): синхронизировать ReUI PRO MCP и frontend patterns
Primary MCP user-reui, Frame surface, матрица preview/docs, REUI_LICENSE_KEY в .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:40:56 +07:00
58 changed files with 1510 additions and 1680 deletions

No files matched your search

+53 -134
View File
@@ -1,183 +1,102 @@
---
description: Frontend — ТОЛЬКО shadcn/ui docs (Components, Blocks, Installation); best practices, CLI-first
description: Frontend — shadcn primitives + ReUI PRO blocks; CLI-first; kit patterns
globs: apps/web/**/*,packages/ui/**/*
alwaysApply: false
---
# Frontend — shadcn/ui (обязательно)
# Frontend — shadcn/ui + ReUI PRO
**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI.
**Источник истины:** MCP **`user-reui`** (pages/blocks/KPI/settings) + MCP `plugin-shadcn-shadcn` (primitives) + docs. Не выдумывать UI.
Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). UI patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-mcp.mdc`](reui-mcp.mdc). Patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL |
|----------|-----|
| **Components (primary catalog)** | https://ui.shadcn.com/docs/components |
| **Blocks** | https://ui.shadcn.com/blocks |
| **shadcn Components** | https://ui.shadcn.com/docs/components |
| **ReUI Blocks** | https://reui.io/blocks |
| **ReUI llms.txt** | https://reui.io/llms.txt |
| **ReUI Settings** | https://reui.io/blocks/application/settings |
| **Installation** | https://ui.shadcn.com/docs/installation |
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
| **Theming** | https://ui.shadcn.com/docs/theming |
| **Dark Mode** | https://ui.shadcn.com/docs/dark-mode |
| **Forms (RHF)** | https://ui.shadcn.com/docs/forms/react-hook-form |
## Шаг 0 — перед любым UI-кодом
0. **Codegraph** `codegraph_explore` — найти существующие реализации
1. **MCP `plugin-shadcn-shadcn`:** `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
2. Открыть **Components** или **Blocks** — найти готовое решение
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [Components](https://ui.shadcn.com/docs/components)
4. Сверить MCP examples ↔ docs API — только потом писать код
5. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
0. Найти существующие `reui-kit/*` / shared
1. **Pages / KPI / settings / enterprise** → MCP `user-reui` (`search` → `get_block` / `compose_page`) + cite `previewUrl`
2. **Primitives** → MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
3. CLI add из `apps/web` → adapt
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
**Новая страница** → сначала ReUI PRO `compose_page` / blocks ([reui.io/blocks](https://reui.io/blocks)), не ui.shadcn.com/blocks как primary.
## Docs workflow
1. MCP `plugin-shadcn-shadcn` — search → examples → add command
2. `pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
3. Skill `.agents/skills/shadcn/SKILL.md` — critical rules
4. Context7 — только TanStack / Recharts
## Shared components (обязательно)
## Shared / kit (обязательно)
| Component | Файл |
|-----------|------|
| `PageShell` | `page-shell.tsx` |
| `PageHeader` | `page-header.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` |
| `OpsDashboard` / `KpiStatGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx` |
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `EmptyState` | `empty-state.tsx` |
| `QueryState` | `query-state.tsx` |
| `ConfirmDialog` | `confirm-dialog.tsx` |
| `DataGridCard` | `data-grid-card.tsx` |
| `SectionCards` | `section-cards.tsx` |
| `StatusBadge` | `status-badge.tsx` |
| `FormSheet` | `form-sheet.tsx` |
| `FormField` | `form-field.tsx` |
| `TableCard` | `table-card.tsx` |
| `LoadingButton` | `loading-button.tsx` |
| `SectionCardsSkeleton` / `TableSkeleton` | `skeletons.tsx` |
| `SettingRow` | `setting-row.tsx` |
**Не использовать как эталон:** устаревшие `PageHeader` / `SectionCards` / `DataGridCard` / `TableCard` (если удалены или мёртвы).
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
## Шаг 1 — CLI (обязательно)
## Шаг 1 — CLI
```bash
cd apps/web
pnpm dlx shadcn@latest add table select badge card field input button ...
pnpm dlx shadcn@latest add sidebar-07 # layout
pnpm dlx shadcn@latest add dashboard-01 # dashboard
pnpm dlx shadcn@latest add login-03 # auth
pnpm dlx shadcn@latest apply b2fA --only theme -y # тема — ТОЛЬКО так
pnpm dlx shadcn@latest add button field input ...
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
pnpm dlx shadcn@latest add @reui/card-35 @reui/settings-16 @reui/auth-13
pnpm dlx shadcn@latest apply b2fA --only theme -y
```
- Копипаст с сайта **без** CLI — запрещено
- `packages/ui/src/components/*` — только registry; domain-логика → `apps/web/src/components/<name>.tsx`
## Приоритет композиции
## Шаг 2 — композиция (best practices)
1. `@cfdm/ui/components/*` (primitives)
2. ReUI PRO block → adapt (`blocks/` reference + kit)
3. `reui-kit/*` + shared
4. Domain-обёртка
### Приоритет
## Запрещено
1. Установленный `@cfdm/ui/components/*`
2. Block из registry (адаптация под TanStack Router)
3. Shared проекта: `PageShell`, `PageHeader`, `EmptyState`, `QueryState`, `ConfirmDialog`, `DataTableCard`, `SectionCards`, `StatusBadge`
4. Domain-обёртка — последний уровень кастомизации
### Запрещено
| ❌ | ✅ из [Components](https://ui.shadcn.com/docs/components) |
|----|-----------------------------------------------------------|
| `<table>`, `<select>`, `<hr>` | `Table`, `Select`, `Separator` |
| `bg-emerald-*`, `text-blue-500`, hex в className | `bg-primary`, `text-muted-foreground`, `Badge variant` |
| Ручной `globals.css`, `.css` модули | CLI `apply b2fA --only theme` |
| `space-y-*` / `space-x-*` | `flex` + `gap-*` |
| `w-10 h-10` | `size-10` |
| `className` для цветов Button/Badge | `variant`, `size` |
| `useState` для полей формы | `FieldGroup` + RHF + Zod |
| Styled `<Link>` | `Button variant="link"` + `render={<Link />}` |
| `inline style={{}}` в routes | layout Tailwind |
| `animate-pulse` div | `Skeleton` |
| кастомный toast | `sonner` → `toast()` |
| ❌ | ✅ |
|----|-----|
| `<table>`, raw select | DataGrid / `Select` |
| `bg-emerald-*` | semantic / ReUI `variant` |
| Card как ops-shell | ReUI `Frame` |
| Hand-roll KPI | `KpiStatGrid` / [card-35](https://reui.io/preview/base/card-35) |
| `space-y-*` | `flex` + `gap-*` |
| `@/components/ui/*` | `@cfdm/ui/components/*` |
### Формы
По https://ui.shadcn.com/docs/forms/react-hook-form:
```tsx
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="name">Имя</FieldLabel>
<Input id="name" aria-invalid={!!errors.name} {...register('name')} />
</Field>
</FieldGroup>
```
### Card
`CardHeader` / `CardTitle` / `CardDescription` / `CardContent` / `CardFooter` — полная композиция из docs.
### Таблицы
`Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` — из docs.
Сложная таблица → [Data Table](https://ui.shadcn.com/docs/components/data-table) + block `dashboard-01`.
### Графики
`Chart` + `ChartContainer` + `chartConfig` с `var(--chart-1)` — не raw recharts без обёртки.
### Иконки в Button
```tsx
<Button>
<PlusIcon data-icon="inline-start" />
Создать
</Button>
```
Без `size-4` на иконке внутри shadcn-компонента.
## Стек (не shadcn, но обязателен)
TanStack Router + Query — [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc).
- Preset: **base-nova** + **neutral** — [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- `@base-ui/react` → `render` prop (не Radix `asChild`)
- **Не Next.js** — нет Server Components, `'use client'`
## Эталоны проекта
| Зона | Файл | Block |
|------|------|-------|
| Shell | `apps/web/src/components/layout/app-shell.tsx` | [sidebar-07](https://ui.shadcn.com/blocks) |
| Login | `apps/web/src/routes/login.tsx` | [login-03](https://ui.shadcn.com/blocks) |
| Dashboard | `apps/web/src/routes/_auth/index.tsx` | [dashboard-01](https://ui.shadcn.com/blocks) |
| CRUD | `routes/_auth/services.tsx`, `domains/index.tsx` | Card + Field + Table |
| Зона | Файл | Preview |
|------|------|---------|
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
| Dashboard | `routes/_auth/index.tsx` | [card-35](https://reui.io/preview/base/card-35) / dashboard-1 |
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
## Структура файлов
## Чеклист
```
apps/web/src/
components/ ← domain + layout + shared (blocks)
routes/ ← страницы (композиция @cfdm/ui, без raw HTML)
queries/ ← queryOptions (не inline в routes)
lib/schemas.ts ← Zod для форм
packages/ui/src/
components/ ← только CLI (не трогать под кейс)
hooks/ ← registry hooks (use-mobile)
lib/utils.ts ← cn()
styles/globals.css ← только output shadcn CLI
```
## Чеклист перед завершением задачи
- [ ] MCP shadcn: search + examples (+ add command при новых примитивах)
- [ ] Решение есть в https://ui.shadcn.com/docs/components или /blocks
- [ ] Компоненты добавлены через `pnpm dlx shadcn@latest add` из `apps/web`
- [ ] Нет кастомного CSS и raw HTML-примитивов
- [ ] Semantic tokens, `variant`/`size` вместо переопределения className
- [ ] UI-импорты через `@cfdm/ui/components/*`
- [ ] `pnpm --filter web build` без ошибок
- [ ] `user-reui` + preview/docs для зоны
- [ ] Frame surface
- [ ] CLI add при новых items
- [ ] `pnpm --filter web build`
## Язык
Ответы пользователю — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
Ответы — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+27 -10
View File
@@ -10,19 +10,34 @@ alwaysApply: false
## Docs workflow (обязательно)
0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты
1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) + ReUI skill — component selection
2. MCP `plugin-shadcn-shadcn` / `user-reui` — search → examples → add command (`surface: "frame"`)
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` / `add @reui/...` → сверить API
4. Код по examples + docs API (только после совпадения MCP ↔ docs)
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn/ReUI primitives)
6. MCP `get_audit_checklist` — перед merge
7. Codegraph `codegraph_status` — Pending sync пустой
0. Codegraph / поиск существующих shared/domain / `reui-kit`
1. Skill ReUI + shadcn — component selection
2. **Primary:** MCP `user-reui` — search → `get_block` / `compose_page` (`surface: "frame"`) — **всегда cite `previewUrl` + `docsUrl`**
3. Primitives: MCP `plugin-shadcn-shadcn` + `pnpm dlx shadcn@latest docs <component>`
4. CLI: `cd apps/web && pnpm dlx shadcn@latest add @reui/...` → сверить API
5. Код по examples + docs (только после MCP ↔ docs)
6. Context7 — **только** TanStack Router/Query, Recharts
7. MCP `validate_usage` / `get_audit_checklist` — перед merge
## Surface
Проект использует **ReUI Frame** (`surface: frame`), не shadcn Card как оболочку list/ops-экранов. Эталон списка: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
## Обязательные референсы по зонам
| Зона | Preview |
|------|---------|
| KPI | [card-35](https://reui.io/preview/base/card-35) |
| List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
| Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
| Auth | [auth-13](https://reui.io/preview/base/auth-13) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
| Forms | [form-7](https://reui.io/preview/base/form-7) |
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
**SettingRow:** `FieldSeparator` только между соседними rows; не между toggle-row и nested fields (Health-check).
## Иерархия компонентов
```
@@ -134,8 +149,10 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
| KPI | [card-35](https://reui.io/preview/base/card-35) |
| Login | auth-13 / login-03 |
| KPI | [card-35](https://reui.io/preview/base/card-35) — на dashboard, certificates, domains, groups, services |
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
## Spacing
+74 -75
View File
@@ -1,109 +1,108 @@
---
description: ReUI registry (@reui) — enterprise-компоненты, MCP workflow, зависимости, матрица выбора shadcn vs ReUI
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
alwaysApply: true
---
# ReUI MCP — обязательно для enterprise-компонентов
# ReUI MCP — обязательно (PRO + free)
ReUI — first-class shadcn registry с enterprise-компонентами (Data Grid, Filters, Stepper и др.). Проект использует **Base UI** (`style: base-nova`), не Radix.
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные правила: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL |
|----------|-----|
| **llms.txt (карта для ИИ)** | https://reui.io/llms.txt |
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling (semantic tokens)** | https://reui.io/docs/styling |
| **Styling** | https://reui.io/docs/styling |
| **MCP** | https://reui.io/docs/mcp |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
| **Base UI components** | https://reui.io/docs/components/base/<name> |
## Когда использовать ReUI vs shadcn
## Primary MCP
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
## Когда 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` |
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@cfdm/ui/components/*` |
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [card-35](https://reui.io/preview/base/card-35) | `reui-kit/KpiStatGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
**Простые списки** — shadcn `Table`. **Сложные data-списки** — `DataGridCard` (ReUI data-grid), не shadcn Data Table.
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не устаревший DataGridCard.
## Источники для ИИ (порядок)
## MCP workflow
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`
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed`
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
5. Post-add: shadcn imports → `@cfdm/ui/components/*`
6. Adapt by reuse → kit / route
7. `validate_usage` + `get_audit_checklist`
## 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` и т.д. |
| shadcn | `packages/ui/src/components/` | `@cfdm/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
```bash
cd apps/web
pnpm dlx shadcn@latest add @reui/data-grid
pnpm dlx shadcn@latest add @reui/filters
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
## Уже установленные ReUI-компоненты
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
`badge`, `data-grid/*`, `filters` — в [`apps/web/src/components/reui/`](apps/web/src/components/reui/).
## Эталоны preview
Перед добавлением дубликата — проверить 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
| Зона | Preview |
|------|---------|
| KPI | https://reui.io/preview/base/card-35 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
| Shell | https://reui.io/preview/base/app-shell-12 |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Запрещено
- Копипаст с reui.io без CLI `add @reui/...`
- Класть ReUI в `packages/ui` или импортировать как `@cfdm/ui`
- Radix-варианты (`/docs/components/radix/...`) — только Base UI
- Raw Tailwind-цвета вместо ReUI semantic `variant`
- Писать data-grid/filters с нуля, если есть `@reui/*` и shared-обёртки
- Копипаст с reui.io без CLI
- ReUI в `packages/ui` / импорт как `@cfdm/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
## Чеклист
- [ ] Задача требует 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` без ошибок
- [ ] `user-reui` search/get_block + previewUrl
- [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@cfdm/ui` — правильный слой
- [ ] `pnpm --filter web build`
+4
View File
@@ -14,6 +14,10 @@ ADMIN_PASSWORD_HASH=
# Frontend (Vite)
# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции
# ReUI PRO (apps/web/components.json → @reui Authorization)
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
REUI_LICENSE_KEY=
# Server
SERVER_PORT=8080
STATIC_DIR=
-38
View File
@@ -1,38 +0,0 @@
import type { ComponentProps } from 'react'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
import { cn } from '@cfdm/ui/lib/utils'
export type AppAccordionProps = ComponentProps<typeof Accordion>
export type AppAccordionItemProps = ComponentProps<typeof AccordionItem>
export type AppAccordionTriggerProps = ComponentProps<typeof AccordionTrigger>
export type AppAccordionContentProps = ComponentProps<typeof AccordionContent>
export function AppAccordion({ className, ...props }: AppAccordionProps) {
return <Accordion className={cn(className)} {...props} />
}
export function AppAccordionItem({
className,
...props
}: AppAccordionItemProps) {
return <AccordionItem className={cn(className)} {...props} />
}
export function AppAccordionTrigger({
className,
...props
}: AppAccordionTriggerProps) {
return <AccordionTrigger className={cn(className)} {...props} />
}
export function AppAccordionContent({
className,
...props
}: AppAccordionContentProps) {
return <AccordionContent className={cn(className)} {...props} />
}
+7 -1
View File
@@ -1,5 +1,10 @@
import type { ComponentProps } from 'react'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
export type AppAlertProps = ComponentProps<typeof Alert>
@@ -7,6 +12,7 @@ export type AppAlertTitleProps = ComponentProps<typeof AlertTitle>
export type AppAlertDescriptionProps = ComponentProps<typeof AlertDescription>
export type AppAlertActionProps = ComponentProps<typeof AlertAction>
/** Thin alias over ReUI Alert — prefer importing from `@/components/reui/alert` in new code. */
export function AppAlert({ className, ...props }: AppAlertProps) {
return <Alert className={cn(className)} {...props} />
}
+2 -1
View File
@@ -1,9 +1,10 @@
import type { ComponentProps } from 'react'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
export type AppBadgeProps = ComponentProps<typeof Badge>
/** Thin alias over ReUI Badge — prefer importing Badge/StatusBadge directly in new code. */
export function AppBadge({ className, ...props }: AppBadgeProps) {
return <Badge className={cn(className)} {...props} />
}
-9
View File
@@ -1,9 +0,0 @@
import type { ComponentProps } from 'react'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { cn } from '@cfdm/ui/lib/utils'
export type AppCheckboxProps = ComponentProps<typeof Checkbox>
export function AppCheckbox({ className, ...props }: AppCheckboxProps) {
return <Checkbox className={cn(className)} {...props} />
}
-9
View File
@@ -1,9 +0,0 @@
import type { ComponentProps } from 'react'
import { Spinner } from '@cfdm/ui/components/spinner'
import { cn } from '@cfdm/ui/lib/utils'
export type AppSpinnerProps = ComponentProps<typeof Spinner>
export function AppSpinner({ className, ...props }: AppSpinnerProps) {
return <Spinner className={cn(className)} {...props} />
}
-9
View File
@@ -1,9 +0,0 @@
import type { ComponentProps } from 'react'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
export type AppSwitchProps = ComponentProps<typeof Switch>
export function AppSwitch({ className, ...props }: AppSwitchProps) {
return <Switch className={cn(className)} {...props} />
}
@@ -5,24 +5,24 @@ import { HealthCheckBadge } from '@/components/health-check-badge'
import { groupBindingsByHostname } from '@/lib/domain-ips'
import { useHealthRows } from '@/lib/use-aggregated-health'
import type { ServiceBinding } from '@/lib/schemas'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import { Badge } from '@/components/reui/badge'
import {
AppCard,
AppCardContent,
AppCardDescription,
AppCardFooter,
AppCardHeader,
AppCardTitle,
} from '@/components/app-card'
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
AppItemSeparator,
AppItemTitle,
} from '@/components/app-item'
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
interface DomainBindingsCardProps {
bindings: ServiceBinding[]
@@ -76,12 +76,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
const entries = [...byHostname.entries()]
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>Привязки сервисов</AppCardTitle>
<AppCardDescription>Сервисы, назначенные hostname в этой зоне</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Привязки сервисов</FrameTitle>
<FrameDescription>Сервисы, назначенные hostname в этой зоне</FrameDescription>
</FrameHeader>
<FramePanel>
{entries.length === 0 ? (
<EmptyState
icon={Link2Icon}
@@ -89,7 +89,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
description="Привяжите сервис к поддомену"
/>
) : (
<AppItemGroup className="gap-0">
<ItemGroup className="gap-0">
{entries.map(([hostname, groupBindings], index) => {
const ips = [
...new Set(
@@ -104,9 +104,9 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
]
return (
<div key={hostname}>
<AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1">
<AppItemTitle className="font-mono">{hostname}</AppItemTitle>
<Item size="sm" variant="muted" className="border-0 px-0">
<ItemContent className="gap-1">
<ItemTitle className="font-mono">{hostname}</ItemTitle>
<div className="text-muted-foreground text-sm">
{uniqueServices(groupBindings).join(', ')}
</div>
@@ -114,30 +114,30 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
bindings={groupBindings}
ips={ips}
/>
</AppItemContent>
<AppItemActions>
<AppBadge variant="outline" className="tabular-nums">
</ItemContent>
<ItemActions>
<Badge variant="outline" className="tabular-nums">
{ips.length} IP
</AppBadge>
</AppItemActions>
</AppItem>
{index < entries.length - 1 ? <AppItemSeparator /> : null}
</Badge>
</ItemActions>
</Item>
{index < entries.length - 1 ? <ItemSeparator /> : null}
</div>
)
})}
</AppItemGroup>
</ItemGroup>
)}
</AppCardContent>
<AppCardFooter>
<AppButton
</FramePanel>
<FrameFooter>
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
>
К сервисам
</AppButton>
</AppCardFooter>
</AppCard>
</Button>
</FrameFooter>
</Frame>
)
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { AppBadge } from '@/components/app-badge'
import { Badge } from '@/components/reui/badge'
import {
AppItem,
AppItemContent,
@@ -19,9 +19,9 @@ interface DomainIpBadgesProps {
function IpBadge({ ip }: { ip: string }) {
return (
<AppBadge variant="secondary" className="font-mono">
<Badge variant="secondary" className="font-mono">
{ip}
</AppBadge>
</Badge>
)
}
@@ -43,7 +43,7 @@ export function DomainIpBadges({ ips }: DomainIpBadgesProps) {
<Tooltip>
<TooltipTrigger
render={
<AppBadge variant="outline" className="cursor-default font-mono tabular-nums" />
<Badge variant="outline" className="cursor-default font-mono tabular-nums" />
}
>
+{rest.length}
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import { GlobeIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { DomainListItem } from '@/lib/schemas'
interface DomainKpiCardsProps {
domains: DomainListItem[] | undefined
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function DomainKpiCards({
domains,
activeTab,
isLoading,
onSelectTab,
}: DomainKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const list = domains ?? []
const withoutGroup = list.filter((d) => d.group_id == null).length
const withServices = list.filter((d) => d.service_count > 0).length
const active = list.filter((d) => d.status === 'active').length
return [
{
id: 'all',
label: 'Всего',
value: list.length,
hint: 'Импортированные зоны',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'without_group',
label: 'Без группы',
value: withoutGroup,
hint: 'Не назначена группа',
onSelect: () => onSelectTab('without_group'),
selected: activeTab === 'without_group',
},
{
id: 'active',
label: 'Active',
value: active,
hint: 'Статус Cloudflare',
onSelect: () => onSelectTab('all'),
selected: false,
},
{
id: 'with_services',
label: 'С сервисами',
value: withServices,
hint: 'Есть привязки',
onSelect: () => onSelectTab('all'),
selected: false,
},
]
}, [domains, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<GlobeIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет доменов — импортируйте зону Cloudflare"
/>
)
}
@@ -18,20 +18,6 @@ import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
AppItemSeparator,
AppItemTitle,
} from '@/components/app-item'
import {
Frame,
FrameDescription,
@@ -40,6 +26,16 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
import {
Select,
SelectContent,
@@ -178,21 +174,19 @@ export function DomainAvailabilityPanel({
createMutation.mutate()
}}
>
<AppFieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<AppField>
<AppFieldLabel htmlFor="monitor-hostname">
Hostname
</AppFieldLabel>
<AppInput
<FieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<Field>
<FieldLabel htmlFor="monitor-hostname">Hostname</FieldLabel>
<Input
id="monitor-hostname"
value={hostname}
placeholder="example.com"
onChange={(e) => setHostname(e.target.value)}
autoComplete="off"
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="monitor-type">Тип</AppFieldLabel>
</Field>
<Field>
<FieldLabel htmlFor="monitor-type">Тип</FieldLabel>
<Select
items={MONITOR_TYPE_ITEMS}
value={monitorType}
@@ -213,7 +207,7 @@ export function DomainAvailabilityPanel({
))}
</SelectContent>
</Select>
</AppField>
</Field>
<LoadingButton
type="submit"
isLoading={createMutation.isPending}
@@ -223,7 +217,7 @@ export function DomainAvailabilityPanel({
<PlusIcon data-icon="inline-start" />
Добавить
</LoadingButton>
</AppFieldGroup>
</FieldGroup>
</form>
</FramePanel>
</Frame>
@@ -251,14 +245,14 @@ export function DomainAvailabilityPanel({
description="Добавьте hostname для проверки доступности"
/>
) : (
<AppItemGroup className="gap-0">
<ItemGroup className="gap-0">
{monitors.map((monitor, index) => (
<div key={monitor.id}>
<AppItem size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1">
<AppItemTitle className="font-mono">
<Item size="sm" variant="muted" className="border-0 px-0">
<ItemContent className="gap-1">
<ItemTitle className="font-mono">
{monitor.hostname}
</AppItemTitle>
</ItemTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<span className="uppercase">
{monitorTypeLabel(monitor.type)}
@@ -271,8 +265,8 @@ export function DomainAvailabilityPanel({
<span>Ещё не проверялся</span>
)}
</div>
</AppItemContent>
<AppItemActions className="gap-2">
</ItemContent>
<ItemActions className="gap-2">
<HealthCheckBadge
status={monitor.last_status}
latencyMs={monitor.last_latency_ms}
@@ -299,14 +293,12 @@ export function DomainAvailabilityPanel({
</Button>
}
/>
</AppItemActions>
</AppItem>
{index < monitors.length - 1 ? (
<AppItemSeparator />
) : null}
</ItemActions>
</Item>
{index < monitors.length - 1 ? <ItemSeparator /> : null}
</div>
))}
</AppItemGroup>
</ItemGroup>
)}
</QueryState>
</FramePanel>
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
@@ -42,89 +44,97 @@ export function DomainsBulkToolbar({
]
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-muted-foreground text-sm tabular-nums">
Выбрано: {count}
</span>
<Select
items={groupItems}
value={groupValue}
onValueChange={(v) => setGroupValue(v ?? 'none')}
>
<SelectTrigger className="w-44" size="sm">
<SelectValue placeholder="Группа" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
</Button>
<Button type="button" variant="ghost" size="sm" disabled={isPending} onClick={onClear}>
Снять выделение
</Button>
</div>
<Frame dense spacing="sm" className="mb-3 w-full">
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
<Badge size="sm" variant="secondary" className="tabular-nums">
{count} выбрано
</Badge>
<Select
items={groupItems}
value={groupValue}
onValueChange={(v) => setGroupValue(v ?? 'none')}
>
<SelectTrigger className="w-44" size="sm">
<SelectValue placeholder="Группа" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
onClick={onClear}
>
Снять выделение
</Button>
</FramePanel>
</Frame>
)
}
+28 -7
View File
@@ -1,4 +1,5 @@
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
@@ -7,6 +8,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { cn } from '@cfdm/ui/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon
@@ -14,6 +16,8 @@ interface EmptyStateProps {
description?: string
action?: React.ReactNode
className?: string
/** Use IconStack media (empty-state-12). Default true. */
stackedIcon?: boolean
}
export function EmptyState({
@@ -22,17 +26,34 @@ export function EmptyState({
description,
action,
className,
stackedIcon = true,
}: EmptyStateProps) {
return (
<Empty className={className ?? 'border-0'}>
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon />
<Empty className={cn('max-w-md flex-none border-0 bg-transparent p-0', className)}>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
{description && <EmptyDescription>{description}</EmptyDescription>}
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
{title}
</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">
{description}
</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action && <EmptyContent>{action}</EmptyContent>}
{action ? <EmptyContent>{action}</EmptyContent> : null}
</Empty>
)
}
@@ -1,129 +0,0 @@
import { Button } from "@cfdm/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@cfdm/ui/components/card"
import { Input } from "@cfdm/ui/components/input"
import { Label } from "@cfdm/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@cfdm/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="alex.chen@example.com"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+7 -3
View File
@@ -3,6 +3,7 @@ import type { Control, FieldPath, FieldValues } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@cfdm/ui/components/field'
@@ -13,6 +14,7 @@ interface FormFieldProps<T extends FieldValues> {
control: Control<T>
label: string
htmlFor?: string
hint?: string
className?: string
children: (props: {
id: string
@@ -29,6 +31,7 @@ export function FormField<T extends FieldValues>({
control,
label,
htmlFor,
hint,
className,
children,
}: FormFieldProps<T>) {
@@ -52,6 +55,9 @@ export function FormField<T extends FieldValues>({
onBlur: field.onBlur,
ref: field.ref,
})}
{hint && !fieldState.error ? (
<FieldDescription>{hint}</FieldDescription>
) : null}
<FieldError errors={[fieldState.error]} />
</Field>
)}
@@ -80,9 +86,7 @@ export function FormFieldSimple({
<Field data-invalid={!!error} className={cn(className)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{hint && !error && (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
<FieldError errors={[error]} />
</Field>
)
@@ -0,0 +1,61 @@
import { useMemo } from 'react'
import { FolderTreeIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { GroupCatalogRow } from '@/components/columns/groups-columns'
interface GroupKpiCardsProps {
rows: GroupCatalogRow[]
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function GroupKpiCards({
rows,
activeTab,
isLoading,
onSelectTab,
}: GroupKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const withDomains = rows.filter((r) => r.domainCount > 0).length
const empty = rows.filter((r) => r.domainCount === 0).length
return [
{
id: 'all',
label: 'Всего',
value: rows.length,
hint: 'Группы доменов',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'with_domains',
label: 'С доменами',
value: withDomains,
hint: 'Непустые группы',
onSelect: () => onSelectTab('with_domains'),
selected: activeTab === 'with_domains',
},
{
id: 'empty',
label: 'Пустые',
value: empty,
hint: 'Без доменов',
onSelect: () => onSelectTab('empty'),
selected: activeTab === 'empty',
},
]
}, [rows, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={3}
emptyIcon={<FolderTreeIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет групп — создайте первую"
/>
)
}
@@ -140,7 +140,7 @@ export function HealthCheckConfigFields({
description="TCP/HTTP проверка цели DNS"
labelFor={`${idPrefix}-enabled`}
compact
last={!value.enabled}
last
className={rowClass}
titleAddon={
<Badge
@@ -0,0 +1,93 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to CFDM App Switcher. */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/integrations" />}
className="justify-center text-sm font-medium"
>
Настроить приложения
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,180 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@cfdm/ui/components/dialog'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/domains',
label: 'Домены',
keywords: ['domains', 'зоны', 'cloudflare'],
icon: GlobeIcon,
},
{
to: '/groups',
label: 'Группы доменов',
keywords: ['groups', 'группы', 'канбан'],
icon: FolderTreeIcon,
},
{
to: '/services',
label: 'Сервисы',
keywords: ['services', 'сервисы', 'fqdn'],
icon: ServerIcon,
},
{
to: '/certificates',
label: 'Сертификаты',
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
keywords: ['settings', 'интеграции', 'app switcher'],
icon: SettingsIcon,
},
] as const
/** Command-K search — app-shell-12 SearchMenu, routes only (no demo data). */
export function SearchMenu() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false)
void navigate({ to })
}
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<SearchIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>
Переход к разделам приложения
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3 border-b px-4 py-2">
<SearchIcon
aria-hidden="true"
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
}
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
>
<ItemMedia variant="icon">
<item.icon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.label}</ItemTitle>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
@@ -9,6 +9,8 @@ import {
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { ModeToggle } from '@/components/mode-toggle'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SearchMenu } from '@/components/layout/search-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
export interface RouteBreadcrumbLoaderData {
@@ -94,8 +96,9 @@ export function SiteHeader() {
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Breadcrumb>
<div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
@@ -120,7 +123,10 @@ export function SiteHeader() {
})}
</BreadcrumbList>
</Breadcrumb>
<div className="text-muted-foreground ml-auto flex items-center gap-2">
</div>
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
<SearchMenu />
<AppsMenu />
<ModeToggle />
</div>
</header>
+34 -28
View File
@@ -9,15 +9,19 @@ import { loginSchema, type LoginInput } from '@/lib/schemas'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import {
AppCard,
AppCardContent,
AppCardDescription,
AppCardHeader,
AppCardTitle,
} from '@/components/app-card'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import { AppAlert, AppAlertDescription, AppAlertTitle } from '@/components/app-alert'
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
type LoginFormProps = React.ComponentProps<'div'>
@@ -45,25 +49,26 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<AppCard>
<AppCardHeader className="text-center">
<AppCardTitle className="text-xl">Вход в систему</AppCardTitle>
<AppCardDescription>
<Frame dense spacing="default" className="w-full">
<FrameHeader className="text-center">
<FrameTitle className="text-xl">Вход в систему</FrameTitle>
<FrameDescription>
Введите учётные данные для доступа к панели управления
</AppCardDescription>
</AppCardHeader>
<AppCardContent>
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit}>
<AppFieldGroup>
<FieldGroup>
<FormFieldSimple
label="Имя пользователя"
htmlFor="username"
error={form.formState.errors.username}
>
<AppInput
<Input
id="username"
placeholder="admin"
autoComplete="username"
className="bg-background"
{...form.register('username')}
aria-invalid={!!form.formState.errors.username}
/>
@@ -73,21 +78,22 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
htmlFor="password"
error={form.formState.errors.password}
>
<AppInput
<Input
id="password"
type="password"
autoComplete="current-password"
className="bg-background"
{...form.register('password')}
aria-invalid={!!form.formState.errors.password}
/>
</FormFieldSimple>
{rootError && (
<AppAlert variant="destructive">
{rootError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AppAlertTitle>Ошибка входа</AppAlertTitle>
<AppAlertDescription>{rootError}</AppAlertDescription>
</AppAlert>
)}
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{rootError}</AlertDescription>
</Alert>
) : null}
<LoadingButton
type="submit"
className="w-full"
@@ -96,10 +102,10 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
>
Войти
</LoadingButton>
</AppFieldGroup>
</FieldGroup>
</form>
</AppCardContent>
</AppCard>
</FramePanel>
</Frame>
</div>
)
}
-47
View File
@@ -1,47 +0,0 @@
import { cn } from '@cfdm/ui/lib/utils'
interface PageHeaderProps {
title: string
description?: string
meta?: React.ReactNode
actions?: React.ReactNode
className?: string
}
export function PageHeader({
title,
description,
meta,
actions,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between',
className,
)}
>
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h1>
{meta && (
<span className="text-sm text-muted-foreground">{meta}</span>
)}
</div>
{description && (
<p className="max-w-2xl text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{actions}
</div>
)}
</div>
)
}
+12 -13
View File
@@ -1,5 +1,9 @@
import { CircleAlertIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@cfdm/ui/components/button'
import { Skeleton } from '@cfdm/ui/components/skeleton'
@@ -39,19 +43,14 @@ export function QueryState({
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Не удалось загрузить данные</AlertTitle>
<AlertDescription>
{error?.message ?? 'Произошла ошибка при загрузке.'}
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Произошла ошибка при загрузке.'}</span>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
{onRetry && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={onRetry}
>
Повторить
</Button>
)}
</Alert>
)
}
-66
View File
@@ -1,66 +0,0 @@
import {
Card,
CardContent,
} from '@cfdm/ui/components/card'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
export interface ResourceListItem {
id: string | number
primary: string
secondary?: string
}
interface ResourceListProps {
items: ResourceListItem[]
emptyTitle?: string
emptyDescription?: string
renderActions?: (item: ResourceListItem) => React.ReactNode
}
export function ResourceList({
items,
emptyTitle = 'Нет записей',
emptyDescription,
renderActions,
}: ResourceListProps) {
if (!items.length) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>{emptyTitle}</EmptyTitle>
{emptyDescription && (
<EmptyDescription>{emptyDescription}</EmptyDescription>
)}
</EmptyHeader>
</Empty>
)
}
return (
<Card>
<CardContent className="p-0">
<ul>
{items.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-2 border-b px-4 py-3 last:border-b-0"
>
<span className="font-medium">{item.primary}</span>
<div className="flex items-center gap-2">
{item.secondary && (
<span className="text-muted-foreground">{item.secondary}</span>
)}
{renderActions?.(item)}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
)
}
@@ -9,7 +9,7 @@ import {
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { FilterIcon, FunnelXIcon } from 'lucide-react'
import { CircleAlertIcon, FilterIcon, FunnelXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
@@ -33,7 +33,11 @@ import {
import { Button } from '@cfdm/ui/components/button'
import { Separator } from '@cfdm/ui/components/separator'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
@@ -222,6 +226,7 @@ export function ResourcePage<T extends object>({
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
@@ -5,6 +5,7 @@ import { SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { Separator } from '@cfdm/ui/components/separator'
export interface SettingsTabConfig {
id: string
@@ -38,19 +39,20 @@ export function SettingsShell({
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="px-1">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<header className="flex flex-col gap-3 px-1">
<div className="flex flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</div>
<Separator />
</header>
<div
className={cn(
'flex gap-6',
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
@@ -3,22 +3,21 @@ import { CSS } from '@dnd-kit/utilities'
import { useEffect, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { StatusBadge } from '@/components/status-badge'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import { Badge } from '@/components/reui/badge'
import {
AppCard,
AppCardAction,
AppCardContent,
AppCardFooter,
AppCardHeader,
AppCardTitle,
} from '@/components/app-card'
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import { AppInput } from '@/components/app-input'
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { cn } from '@cfdm/ui/lib/utils'
import type { ServiceBinding } from '@/lib/schemas'
@@ -49,32 +48,31 @@ export function ServiceBindingCard({
: undefined
return (
<AppCard
<Frame
ref={setNodeRef}
size="sm"
dense
spacing="sm"
style={style}
className={cn(
'cursor-grab bg-card active:cursor-grabbing',
'cursor-grab active:cursor-grabbing',
isDragging && 'opacity-60 shadow-lg',
)}
{...listeners}
{...attributes}
>
<AppCardHeader>
<AppCardTitle>{binding.zone_name}</AppCardTitle>
<AppCardAction>
{binding.group_name ? (
<AppBadge variant="secondary">{binding.group_name}</AppBadge>
) : (
<AppBadge variant="outline">Без группы</AppBadge>
)}
</AppCardAction>
</AppCardHeader>
<AppCardContent>
<AppFieldGroup className="flex flex-col gap-3">
<AppField>
<AppFieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</AppFieldLabel>
<AppInput
<FrameHeader className="flex-row items-start justify-between gap-2">
<FrameTitle>{binding.zone_name}</FrameTitle>
{binding.group_name ? (
<Badge variant="secondary">{binding.group_name}</Badge>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
<FieldGroup className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</FieldLabel>
<Input
id={`hostname-${binding.id}`}
value={hostname}
placeholder="@"
@@ -87,10 +85,10 @@ export function ServiceBindingCard({
}
}}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor={`ip-${binding.id}`}>IPv4</AppFieldLabel>
<AppInput
</Field>
<Field>
<FieldLabel htmlFor={`ip-${binding.id}`}>IPv4</FieldLabel>
<Input
id={`ip-${binding.id}`}
value={ip}
placeholder="192.168.1.1"
@@ -103,15 +101,15 @@ export function ServiceBindingCard({
}
}}
/>
</AppField>
</AppFieldGroup>
</Field>
</FieldGroup>
<div className="flex flex-wrap items-center gap-2">
<AppBadge>{binding.service_name}</AppBadge>
<Badge>{binding.service_name}</Badge>
{binding.sync_status && <StatusBadge status={binding.sync_status} />}
</div>
</AppCardContent>
<AppCardFooter>
<AppButton
</FramePanel>
<FrameFooter>
<Button
variant="outline"
size="sm"
render={
@@ -122,8 +120,8 @@ export function ServiceBindingCard({
}
>
Домен
</AppButton>
</AppCardFooter>
</AppCard>
</Button>
</FrameFooter>
</Frame>
)
}
+59 -64
View File
@@ -19,19 +19,6 @@ import type {
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import { AppButton } from '@/components/app-button'
import { AppInput } from '@/components/app-input'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import {
AppItem,
AppItemContent,
AppItemGroup,
} from '@/components/app-item'
import { AppSeparator } from '@/components/app-separator'
import {
Sheet,
SheetContent,
@@ -40,6 +27,14 @@ import {
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
@@ -405,27 +400,27 @@ export function ServiceEditSheet({
listClassName="mb-0 w-full"
>
<TabsContent value="general" className="flex flex-col gap-4">
<AppFieldGroup className="flex flex-col gap-4">
<AppField>
<AppFieldLabel htmlFor="edit-service-name">Название</AppFieldLabel>
<AppInput
<FieldGroup className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
id="edit-service-name"
value={name}
placeholder={isCreate ? 'VPN Panel' : undefined}
onChange={(e) => setName(e.target.value)}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-slug">Slug</AppFieldLabel>
<AppInput
</Field>
<Field>
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
<Input
id="edit-service-slug"
value={slug}
placeholder={isCreate ? 'vpn-panel' : undefined}
onChange={(e) => setSlug(e.target.value)}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-group">Группа сервисов</AppFieldLabel>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
@@ -442,9 +437,9 @@ export function ServiceEditSheet({
))}
</SelectContent>
</Select>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</AppFieldLabel>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
@@ -452,8 +447,8 @@ export function ServiceEditSheet({
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</AppField>
</AppFieldGroup>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
@@ -464,9 +459,9 @@ export function ServiceEditSheet({
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<AppField>
<AppFieldLabel htmlFor="service-lb-weight">Вес</AppFieldLabel>
<AppInput
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
@@ -477,10 +472,10 @@ export function ServiceEditSheet({
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="service-lb-priority">Приоритет</AppFieldLabel>
<AppInput
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
@@ -491,7 +486,7 @@ export function ServiceEditSheet({
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
</Field>
</div>
</div>
</>
@@ -503,10 +498,10 @@ export function ServiceEditSheet({
<p className="text-sm text-muted-foreground">
FQDN → IP или CNAME для DNS-записей Cloudflare
</p>
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</AppButton>
</Button>
</div>
{bindings.length === 0 ? (
@@ -515,14 +510,14 @@ export function ServiceEditSheet({
title="Нет привязок"
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
action={
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</AppButton>
</Button>
}
/>
) : (
<AppItemGroup className="gap-2">
<ItemGroup className="gap-2">
{bindings.map((binding, index) => {
const showLbBlock =
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
@@ -532,11 +527,11 @@ export function ServiceEditSheet({
binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin'
return (
<AppItem key={`binding-${index}`} variant="outline" className="items-stretch">
<AppItemContent className="w-full flex flex-col gap-3">
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-end gap-2">
<AppField className="min-w-0 flex-1">
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
@@ -546,8 +541,8 @@ export function ServiceEditSheet({
}
maxItems={1}
/>
</AppField>
<AppButton
</Field>
<Button
type="button"
variant="ghost"
size="icon-sm"
@@ -556,10 +551,10 @@ export function ServiceEditSheet({
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</Button>
</div>
<AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
@@ -578,22 +573,22 @@ export function ServiceEditSheet({
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</AppField>
</Field>
{binding.record_type === 'CNAME' ? (
<AppField>
<AppFieldLabel htmlFor={`binding-cname-${index}`}>
<Field>
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</AppFieldLabel>
<AppInput
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</AppField>
</Field>
) : (
<AppField>
<AppFieldLabel htmlFor={`binding-ip-${index}`}>IP</AppFieldLabel>
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
@@ -606,7 +601,7 @@ export function ServiceEditSheet({
handleBindingMetaChange(index, ip, meta)
}
/>
</AppField>
</Field>
)}
{showLbBlock ? (
@@ -629,17 +624,17 @@ export function ServiceEditSheet({
idPrefix={`binding-${index}-health`}
/>
) : null}
</AppItemContent>
</AppItem>
</ItemContent>
</Item>
)
})}
</AppItemGroup>
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
</div>
<AppSeparator />
<Separator />
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
{!isCreate ? (
@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import { ServerIcon } from 'lucide-react'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
import type { ServiceCatalogRow } from '@/components/columns/services-columns'
interface ServiceKpiCardsProps {
rows: ServiceCatalogRow[]
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
/** KPI strip — ReUI PRO [card-35](https://reui.io/preview/base/card-35) */
export function ServiceKpiCards({
rows,
activeTab,
isLoading,
onSelectTab,
}: ServiceKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const enabled = rows.filter((r) => r.enabled).length
const disabled = rows.filter((r) => !r.enabled).length
const ungrouped = rows.filter((r) => r.groupId == null).length
const down = rows.filter((r) => r.service.health_status === 'down').length
return [
{
id: 'all',
label: 'Всего',
value: rows.length,
hint: 'Сервисы',
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'enabled',
label: 'Включены',
value: enabled,
hint: 'Активные',
onSelect: () => onSelectTab('enabled'),
selected: activeTab === 'enabled',
},
{
id: 'disabled',
label: 'Выключены',
value: disabled,
hint: 'Отключены',
onSelect: () => onSelectTab('disabled'),
selected: activeTab === 'disabled',
},
{
id: 'ungrouped',
label: 'Без группы',
value: ungrouped,
hint: down > 0 ? `Down: ${down}` : 'Не в группе',
onSelect: () => onSelectTab('ungrouped'),
selected: activeTab === 'ungrouped',
},
]
}, [rows, activeTab, onSelectTab])
return (
<KpiStatGrid
cards={cards}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<ServerIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет сервисов — создайте первый"
/>
)
}
@@ -193,6 +193,8 @@ interface ServicesGroupedCatalogProps {
isLoading?: boolean
primaryAction?: ReactNode
togglingId: number | null
activeTab?: string
onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
@@ -209,6 +211,8 @@ export function ServicesGroupedCatalog({
isLoading = false,
primaryAction,
togglingId,
activeTab: controlledTab,
onTabChange,
onEditService,
onDeleteService,
onToggleService,
@@ -217,7 +221,9 @@ export function ServicesGroupedCatalog({
onAddServiceToGroup,
emptyAction,
}: ServicesGroupedCatalogProps) {
const [tab, setTab] = useState('all')
const [internalTab, setInternalTab] = useState('all')
const tab = controlledTab ?? internalTab
const setTab = onTabChange ?? setInternalTab
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
+6 -6
View File
@@ -1,10 +1,10 @@
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { Frame, FramePanel } from '@/components/reui/frame'
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<Frame dense spacing="sm" className="w-full">
<FramePanel className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
@@ -12,14 +12,14 @@ export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: nu
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
<div className="flex gap-2 border-b p-3 last:border-b-0" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
+116 -92
View File
@@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppInput } from '@/components/app-input'
import { AppFieldDescription, AppFieldGroup } from '@/components/app-field'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
@@ -14,20 +18,14 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
export interface SubdomainEditValues {
name: string
serviceId: string
certMonitoring: CertMonitoring
}
const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
})
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
interface SubdomainEditSheetProps {
mode: 'create' | 'edit'
@@ -54,9 +52,14 @@ export function SubdomainEditSheet({
onOpenChange,
onSubmit,
}: SubdomainEditSheetProps) {
const [name, setName] = useState('')
const [serviceId, setServiceId] = useState('none')
const [certMonitoring, setCertMonitoring] = useState<CertMonitoring>('auto')
const form = useForm<SubdomainEditValues>({
resolver: zodResolver(subdomainEditSchema),
defaultValues: {
name: '',
serviceId: 'none',
certMonitoring: 'auto',
},
})
const serviceItems = useMemo(
() => [
@@ -84,55 +87,83 @@ export function SubdomainEditSheet({
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
setName(subdomain.name)
setServiceId(currentServiceId || 'none')
setCertMonitoring(subdomain.cert_monitoring)
form.reset({
name: subdomain.name,
serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
})
return
}
setName('')
setServiceId('none')
setCertMonitoring('auto')
}, [open, mode, subdomain, currentServiceId])
form.reset({
name: '',
serviceId: 'none',
certMonitoring: 'auto',
})
}, [open, mode, subdomain, currentServiceId, form])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, serviceId, certMonitoring })
const certMonitoring = form.watch('certMonitoring')
const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
function handleSubmit(values: SubdomainEditValues) {
onSubmit({
name: values.name.trim(),
serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'}
</SheetTitle>
<SheetDescription>
{mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
<AppFieldGroup>
<FormFieldSimple label="Имя" htmlFor="subdomain_name">
<AppInput
id="subdomain_name"
placeholder="www"
value={name}
onChange={(event) => setName(event.target.value)}
className="font-mono"
aria-invalid={!name.trim() && name.length > 0}
/>
</FormFieldSimple>
{mode === 'edit' && (
<>
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<FormSheet<SubdomainEditValues>
open={open}
onOpenChange={onOpenChange}
title={
mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'
}
description={
mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`
}
form={form}
onSubmit={handleSubmit}
footer={
<LoadingButton
type="submit"
className="w-full"
disabled={!form.watch('name')?.trim()}
isLoading={isSaving}
loadingLabel="Сохранение…"
>
{mode === 'create' ? 'Создать' : 'Сохранить'}
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple
label="Имя"
htmlFor="subdomain_name"
error={form.formState.errors.name}
>
<Input
id="subdomain_name"
placeholder="www"
className="font-mono"
aria-invalid={!!form.formState.errors.name}
{...form.register('name')}
/>
</FormFieldSimple>
{mode === 'edit' ? (
<>
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<Controller
control={form.control}
name="serviceId"
render={({ field }) => (
<Select
items={serviceItems}
value={serviceId}
onValueChange={(value) => setServiceId(value ?? 'none')}
value={field.value}
onValueChange={(value) => field.onChange(value ?? 'none')}
>
<SelectTrigger id="subdomain_service" className="w-full">
<SelectValue placeholder="Без сервиса" />
@@ -145,19 +176,29 @@ export function SubdomainEditSheet({
))}
</SelectContent>
</Select>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
>
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
hint={certHint}
>
<Controller
control={form.control}
name="certMonitoring"
render={({ field }) => (
<Select
items={certMonitoringItems}
value={certMonitoring}
value={field.value}
onValueChange={(value) =>
setCertMonitoring((value ?? 'auto') as CertMonitoring)
field.onChange((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger id="subdomain_cert_monitoring" className="w-full">
<SelectTrigger
id="subdomain_cert_monitoring"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -168,29 +209,12 @@ export function SubdomainEditSheet({
))}
</SelectContent>
</Select>
<AppFieldDescription>
{
certMonitoringOptions.find((o) => o.value === certMonitoring)
?.description
}
</AppFieldDescription>
</FormFieldSimple>
</>
)}
</AppFieldGroup>
<SheetFooter>
<LoadingButton
type="submit"
className="w-full"
disabled={!name.trim()}
isLoading={isSaving}
loadingLabel="Сохранение…"
>
{mode === 'create' ? 'Создать' : 'Сохранить'}
</LoadingButton>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)}
/>
</FormFieldSimple>
</>
) : null}
</FieldGroup>
</FormSheet>
)
}
-42
View File
@@ -1,42 +0,0 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { InboxIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { cn } from '@cfdm/ui/lib/utils'
interface TableCardProps {
children: ReactNode
className?: string
isEmpty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyIcon?: LucideIcon
emptyAction?: ReactNode
}
export function TableCard({
children,
className,
isEmpty,
emptyTitle,
emptyDescription,
emptyIcon: EmptyIcon = InboxIcon,
emptyAction,
}: TableCardProps) {
if (isEmpty && emptyTitle) {
return (
<EmptyState
icon={EmptyIcon}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
)
}
return (
<div className={cn('overflow-hidden rounded-lg border', className)}>
{children}
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
import { SearchIcon } from 'lucide-react'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
interface TableToolbarProps {
value: string
onChange: (value: string) => void
placeholder?: string
}
export function TableToolbar({
value,
onChange,
placeholder = 'Поиск…',
}: TableToolbarProps) {
return (
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={placeholder}
/>
</InputGroup>
)
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { AppBadge } from '@/components/app-badge'
import { Badge } from '@/components/reui/badge'
import {
InputGroup,
InputGroupButton,
@@ -78,7 +78,7 @@ export function TaggedInput({
)}
>
{value.map((tag) => (
<AppBadge key={tag} variant="secondary" className="gap-1 pr-1">
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
{tag}
<InputGroupButton
type="button"
@@ -90,7 +90,7 @@ export function TaggedInput({
>
<XIcon />
</InputGroupButton>
</AppBadge>
</Badge>
))}
<InputGroupInput
id={id}
@@ -1,9 +0,0 @@
import type { SVGProps } from "react";
const Apple = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" />
</svg>
);
export { Apple };
@@ -1,12 +0,0 @@
import type { SVGProps } from "react";
const AppleDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path
fill="#fff"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
/>
</svg>
);
export { AppleDark };
@@ -1,12 +0,0 @@
import type { SVGProps } from "react";
const ClaudeAiIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257">
<path
fill="#D97757"
d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
/>
</svg>
);
export { ClaudeAiIcon };
@@ -1,9 +0,0 @@
import type { SVGProps } from "react";
const CursorDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09" fill="#fff">
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
</svg>
);
export { CursorDark };
@@ -1,9 +0,0 @@
import type { SVGProps } from "react";
const CursorLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
</svg>
);
export { CursorLight };
@@ -1,15 +0,0 @@
import type { SVGProps } from "react";
const GithubDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1024 1024" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
transform="scale(64)"
fill="#ffff"
/>
</svg>
);
export { GithubDark };
@@ -1,14 +0,0 @@
import type { SVGProps } from "react";
const GithubLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1024 1024" fill="none">
<path
fill="#1b1f23"
fillRule="evenodd"
d="M512 0C229.12 0 0 229.12 0 512c0 226.56 146.56 417.92 350.08 485.76 25.6 4.48 35.2-10.88 35.2-24.32 0-12.16-.64-52.48-.64-95.36-128.64 23.68-161.92-31.36-172.16-60.16-5.76-14.72-30.72-60.16-52.48-72.32-17.92-9.6-43.52-33.28-.64-33.92 40.32-.64 69.12 37.12 78.72 52.48 46.08 77.44 119.68 55.68 149.12 42.24 4.48-33.28 17.92-55.68 32.64-68.48-113.92-12.8-232.96-56.96-232.96-252.8 0-55.68 19.84-101.76 52.48-137.6-5.12-12.8-23.04-65.28 5.12-135.68 0 0 42.88-13.44 140.8 52.48 40.96-11.52 84.48-17.28 128-17.28s87.04 5.76 128 17.28c97.92-66.56 140.8-52.48 140.8-52.48 28.16 70.4 10.24 122.88 5.12 135.68 32.64 35.84 52.48 81.28 52.48 137.6 0 196.48-119.68 240-233.6 252.8 18.56 16 34.56 46.72 34.56 94.72 0 68.48-.64 123.52-.64 140.8 0 13.44 9.6 29.44 35.2 24.32C877.44 929.92 1024 737.92 1024 512 1024 229.12 794.88 0 512 0"
clipRule="evenodd"
/>
</svg>
);
export { GithubLight };
-243
View File
@@ -1,243 +0,0 @@
import type { SVGProps } from "react";
const Google = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
overflow="hidden"
viewBox="0 0 268.152 273.883"
>
<defs>
<linearGradient id="a">
<stop offset="0" stopColor="#0fbc5c" />
<stop offset="1" stopColor="#0cba65" />
</linearGradient>
<linearGradient id="g">
<stop offset=".231" stopColor="#0fbc5f" />
<stop offset=".312" stopColor="#0fbc5f" />
<stop offset=".366" stopColor="#0fbc5e" />
<stop offset=".458" stopColor="#0fbc5d" />
<stop offset=".54" stopColor="#12bc58" />
<stop offset=".699" stopColor="#28bf3c" />
<stop offset=".771" stopColor="#38c02b" />
<stop offset=".861" stopColor="#52c218" />
<stop offset=".915" stopColor="#67c30f" />
<stop offset="1" stopColor="#86c504" />
</linearGradient>
<linearGradient id="h">
<stop offset=".142" stopColor="#1abd4d" />
<stop offset=".248" stopColor="#6ec30d" />
<stop offset=".312" stopColor="#8ac502" />
<stop offset=".366" stopColor="#a2c600" />
<stop offset=".446" stopColor="#c8c903" />
<stop offset=".54" stopColor="#ebcb03" />
<stop offset=".616" stopColor="#f7cd07" />
<stop offset=".699" stopColor="#fdcd04" />
<stop offset=".771" stopColor="#fdce05" />
<stop offset=".861" stopColor="#ffce0a" />
</linearGradient>
<linearGradient id="f">
<stop offset=".316" stopColor="#ff4c3c" />
<stop offset=".604" stopColor="#ff692c" />
<stop offset=".727" stopColor="#ff7825" />
<stop offset=".885" stopColor="#ff8d1b" />
<stop offset="1" stopColor="#ff9f13" />
</linearGradient>
<linearGradient id="b">
<stop offset=".231" stopColor="#ff4541" />
<stop offset=".312" stopColor="#ff4540" />
<stop offset=".458" stopColor="#ff4640" />
<stop offset=".54" stopColor="#ff473f" />
<stop offset=".699" stopColor="#ff5138" />
<stop offset=".771" stopColor="#ff5b33" />
<stop offset=".861" stopColor="#ff6c29" />
<stop offset="1" stopColor="#ff8c18" />
</linearGradient>
<linearGradient id="d">
<stop offset=".408" stopColor="#fb4e5a" />
<stop offset="1" stopColor="#ff4540" />
</linearGradient>
<linearGradient id="c">
<stop offset=".132" stopColor="#0cba65" />
<stop offset=".21" stopColor="#0bb86d" />
<stop offset=".297" stopColor="#09b479" />
<stop offset=".396" stopColor="#08ad93" />
<stop offset=".477" stopColor="#0aa6a9" />
<stop offset=".568" stopColor="#0d9cc6" />
<stop offset=".667" stopColor="#1893dd" />
<stop offset=".769" stopColor="#258bf1" />
<stop offset=".859" stopColor="#3086ff" />
</linearGradient>
<linearGradient id="e">
<stop offset=".366" stopColor="#ff4e3a" />
<stop offset=".458" stopColor="#ff8a1b" />
<stop offset=".54" stopColor="#ffa312" />
<stop offset=".616" stopColor="#ffb60c" />
<stop offset=".771" stopColor="#ffcd0a" />
<stop offset=".861" stopColor="#fecf0a" />
<stop offset=".915" stopColor="#fecf08" />
<stop offset="1" stopColor="#fdcd01" />
</linearGradient>
<linearGradient
xlinkHref="#a"
id="s"
x1="219.7"
x2="254.467"
y1="329.535"
y2="329.535"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#b"
id="m"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 1.043 1.45573 2.55542 290.525 -400.634)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#c"
id="n"
cx="45.259"
cy="279.274"
r="71.46"
fx="45.259"
fy="279.274"
gradientTransform="matrix(-3.5126 -4.45809 -1.69255 1.26062 870.8 191.554)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#d"
id="l"
cx="304.017"
cy="118.009"
r="47.854"
fx="304.017"
fy="118.009"
gradientTransform="matrix(2.06435 0 0 2.59204 -297.679 -151.747)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#e"
id="o"
cx="181.001"
cy="177.201"
r="71.46"
fx="181.001"
fy="177.201"
gradientTransform="matrix(-.24858 2.08314 2.96249 .33417 -255.146 -331.164)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#f"
id="p"
cx="207.673"
cy="108.097"
r="41.102"
fx="207.673"
fy="108.097"
gradientTransform="matrix(-1.2492 1.34326 -3.89684 -3.4257 880.501 194.905)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#g"
id="r"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 -1.043 1.45573 -2.55542 290.525 838.683)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#h"
id="j"
cx="154.87"
cy="145.969"
r="71.46"
fx="154.87"
fy="145.969"
gradientTransform="matrix(-.0814 -1.93722 2.92674 -.11625 -215.135 632.86)"
gradientUnits="userSpaceOnUse"
/>
<filter
id="q"
width="1.097"
height="1.116"
x="-.048"
y="-.058"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="1.701" />
</filter>
<filter
id="k"
width="1.033"
height="1.02"
x="-.017"
y="-.01"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation=".242" />
</filter>
<clipPath id="i" clipPathUnits="userSpaceOnUse">
<path d="M371.378 193.24H237.083v53.438h77.167c-1.241 7.563-4.026 15.003-8.105 21.786-4.674 7.773-10.451 13.69-16.373 18.196-17.74 13.498-38.42 16.258-52.783 16.258-36.283 0-67.283-23.286-79.285-54.928-.484-1.149-.805-2.335-1.197-3.507a81.115 81.115 0 0 1-4.101-25.448c0-9.226 1.569-18.057 4.43-26.398 11.285-32.897 42.985-57.467 80.179-57.467 7.481 0 14.685.884 21.517 2.648a77.668 77.668 0 0 1 33.425 18.25l40.834-39.712c-24.839-22.616-57.219-36.32-95.844-36.32-30.878 0-59.386 9.553-82.748 25.7-18.945 13.093-34.483 30.625-44.97 50.985-9.753 18.879-15.094 39.8-15.094 62.294 0 22.495 5.35 43.633 15.103 62.337v.126c10.302 19.857 25.368 36.954 43.678 49.988 15.997 11.386 44.68 26.551 84.031 26.551 22.63 0 42.687-4.051 60.375-11.644 12.76-5.478 24.065-12.622 34.301-21.804 13.525-12.132 24.117-27.139 31.347-44.404 7.23-17.265 11.097-36.79 11.097-57.957 0-9.858-.998-19.87-2.689-28.968Z" />
</clipPath>
</defs>
<g clipPath="url(#i)" transform="matrix(.95792 0 0 .98525 -90.174 -78.856)">
<path
fill="url(#j)"
d="M92.076 219.958c.148 22.14 6.501 44.983 16.117 63.424v.127c6.949 13.392 16.445 23.97 27.26 34.452l65.327-23.67c-12.36-6.235-14.246-10.055-23.105-17.026-9.054-9.066-15.802-19.473-20.004-31.677h-.17l.17-.127c-2.765-8.058-3.037-16.613-3.14-25.503Z"
filter="url(#k)"
/>
<path
fill="url(#l)"
d="M237.083 79.025c-6.456 22.526-3.988 44.421 0 57.161 7.457.006 14.64.888 21.45 2.647a77.662 77.662 0 0 1 33.424 18.25l41.88-40.726c-24.81-22.59-54.667-37.297-96.754-37.332Z"
filter="url(#k)"
/>
<path
fill="url(#m)"
d="M236.943 78.847c-31.67 0-60.91 9.798-84.871 26.359a145.533 145.533 0 0 0-24.332 21.15c-1.904 17.744 14.257 39.551 46.262 39.37 15.528-17.936 38.495-29.542 64.056-29.542l.07.002-1.044-57.335c-.048 0-.093-.004-.14-.004Z"
filter="url(#k)"
/>
<path
fill="url(#n)"
d="m341.475 226.379-28.268 19.285c-1.24 7.562-4.028 15.002-8.107 21.786-4.674 7.772-10.45 13.69-16.373 18.196-17.702 13.47-38.328 16.244-52.687 16.255-14.842 25.102-17.444 37.675 1.043 57.934 22.877-.016 43.157-4.117 61.046-11.796 12.931-5.551 24.388-12.792 34.761-22.097 13.706-12.295 24.442-27.503 31.769-45 7.327-17.497 11.245-37.282 11.245-58.734Z"
filter="url(#k)"
/>
<path
fill="#3086ff"
d="M234.996 191.21v57.498h136.006c1.196-7.874 5.152-18.064 5.152-26.5 0-9.858-.996-21.899-2.687-30.998Z"
filter="url(#k)"
/>
<path
fill="url(#o)"
d="M128.39 124.327c-8.394 9.119-15.564 19.326-21.249 30.364-9.753 18.879-15.094 41.83-15.094 64.324 0 .317.026.627.029.944 4.32 8.224 59.666 6.649 62.456 0-.004-.31-.039-.613-.039-.924 0-9.226 1.57-16.026 4.43-24.367 3.53-10.289 9.056-19.763 16.123-27.926 1.602-2.031 5.875-6.397 7.121-9.016.475-.997-.862-1.557-.937-1.908-.083-.393-1.876-.077-2.277-.37-1.275-.929-3.8-1.414-5.334-1.845-3.277-.921-8.708-2.953-11.725-5.06-9.536-6.658-24.417-14.612-33.505-24.216Z"
filter="url(#k)"
/>
<path
fill="url(#p)"
d="M162.099 155.857c22.112 13.301 28.471-6.714 43.173-12.977l-25.574-52.664a144.74 144.74 0 0 0-26.543 14.504c-12.316 8.512-23.192 18.9-32.176 30.72Z"
filter="url(#q)"
/>
<path
fill="url(#r)"
d="M171.099 290.222c-29.683 10.641-34.33 11.023-37.062 29.29a144.806 144.806 0 0 0 16.792 13.984c15.996 11.386 46.766 26.551 86.118 26.551.046 0 .09-.004.137-.004v-59.157l-.094.002c-14.736 0-26.512-3.843-38.585-10.527-2.977-1.648-8.378 2.777-11.123.799-3.786-2.729-12.9 2.35-16.183-.938Z"
filter="url(#k)"
/>
<path
fill="url(#s)"
d="M219.7 299.023v59.996c5.506.64 11.236 1.028 17.247 1.028 6.026 0 11.855-.307 17.52-.872v-59.748a105.119 105.119 0 0 1-17.477 1.461c-5.932 0-11.7-.686-17.29-1.865Z"
filter="url(#k)"
opacity=".5"
/>
</g>
</svg>
);
export { Google };
@@ -1,41 +0,0 @@
import type { SVGProps } from "react";
const GoogleCalendar = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 512 512" fill="none">
<g clipPath="url(#clip0_5072_3766)">
<path
d="M390.736 121.264H121.264V390.736H390.736V121.264Z"
fill="white"
/>
<path
d="M390.736 512L512 390.736L451.368 380.392L390.736 390.736L379.67 446.196L390.736 512Z"
fill="#EA4335"
/>
<path
d="M0 390.736V471.578C0 493.912 18.088 512 40.42 512H121.264L133.714 451.368L121.264 390.736L55.198 380.392L0 390.736Z"
fill="#188038"
/>
<path
d="M512 121.264V40.42C512 18.088 493.912 0 471.58 0H390.736C383.36 30.072 379.671 52.2027 379.67 66.392C379.67 80.58 383.359 98.8707 390.736 121.264C417.556 128.944 437.767 132.784 451.368 132.784C464.969 132.784 485.18 128.945 512 121.264Z"
fill="#1967D2"
/>
<path d="M512 121.264H390.736V390.736H512V121.264Z" fill="#FBBC04" />
<path d="M390.736 390.736H121.264V512H390.736V390.736Z" fill="#34A853" />
<path
d="M390.736 0H40.422C18.088 0 0 18.088 0 40.42V390.736H121.264V121.264H390.736V0Z"
fill="#4285F4"
/>
<path
d="M176.54 330.308C166.468 323.504 159.494 313.568 155.688 300.428L179.066 290.796C181.186 298.88 184.891 305.145 190.182 309.592C195.436 314.038 201.836 316.228 209.314 316.228C216.959 316.228 223.527 313.903 229.018 309.254C234.51 304.606 237.272 298.678 237.272 291.504C237.272 284.16 234.375 278.164 228.582 273.516C222.788 268.868 215.512 266.544 206.822 266.544H193.314V243.404H205.44C212.917 243.404 219.216 241.382 224.336 237.338C229.456 233.298 232.016 227.772 232.016 220.732C232.016 214.468 229.726 209.482 225.146 205.744C220.566 202.004 214.77 200.118 207.73 200.118C200.858 200.118 195.402 201.938 191.36 205.608C187.319 209.289 184.282 213.937 182.534 219.116L159.394 209.482C162.458 200.792 168.084 193.112 176.336 186.476C184.588 179.84 195.132 176.506 207.932 176.506C217.398 176.506 225.92 178.326 233.466 181.996C241.01 185.668 246.938 190.754 251.216 197.222C255.496 203.722 257.616 210.998 257.616 219.082C257.616 227.334 255.63 234.308 251.656 240.034C247.682 245.76 242.796 250.138 237.002 253.204V254.584C244.483 257.669 250.982 262.735 255.798 269.238C260.682 275.806 263.142 283.654 263.142 292.818C263.142 301.978 260.816 310.164 256.168 317.338C251.52 324.514 245.088 330.172 236.934 334.282C228.75 338.392 219.554 340.482 209.348 340.482C197.524 340.514 186.612 337.112 176.54 330.308ZM320.132 214.298L294.466 232.858L281.632 213.39L327.678 180.176H345.328V336.842H320.132V214.298Z"
fill="#4285F4"
/>
</g>
<defs>
<clipPath id="clip0_5072_3766">
<rect width="512" height="512" fill="white" />
</clipPath>
</defs>
</svg>
);
export { GoogleCalendar };
@@ -1,12 +0,0 @@
import type { SVGProps } from "react";
const ResendIconBlack = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1800 1800" fill="none">
<path
d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z"
fill="black"
/>
</svg>
);
export { ResendIconBlack };
@@ -1,12 +0,0 @@
import type { SVGProps } from "react";
const ResendIconWhite = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1800 1800" fill="none">
<path
d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z"
fill="#FDFDFD"
/>
</svg>
);
export { ResendIconWhite };
-26
View File
@@ -1,26 +0,0 @@
import type { SVGProps } from "react";
const Slack = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 2447.6 2452.5">
<g clipRule="evenodd" fillRule="evenodd">
<path
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
fill="#36c5f0"
/>
<path
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
fill="#2eb67d"
/>
<path
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
fill="#ecb22e"
/>
<path
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
fill="#e01e5a"
/>
</g>
</svg>
);
export { Slack };
@@ -1,14 +0,0 @@
import type { SVGProps } from "react";
const Stripe = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" viewBox="100 100 312 312">
<path
fill="#533afd"
fillRule="evenodd"
d="m120 392 272-57.683V120l-272 58.357z"
clipRule="evenodd"
/>
</svg>
);
export { Stripe };
@@ -1,45 +0,0 @@
import type { SVGProps } from "react";
const Supabase = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 109 113" fill="none">
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint0_linear)"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint1_linear)"
fillOpacity="0.2"
/>
<path
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
fill="#3ECF8E"
/>
<defs>
<linearGradient
id="paint0_linear"
x1="53.9738"
y1="54.974"
x2="94.1635"
y2="71.8295"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#249361" />
<stop offset="1" stopColor="#3ECF8E" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="36.1558"
y1="30.578"
x2="54.4844"
y2="65.0806"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
export { Supabase };
@@ -24,6 +24,7 @@ import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import {
SUBDOMAIN_TABS,
@@ -53,12 +54,7 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
loader: async ({ context: { queryClient }, params }) => {
@@ -327,50 +323,26 @@ function DomainOverviewPage() {
actions={headerActions}
/>
<Tabs
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'dns', label: 'DNS' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: subdomainRows.length,
},
{
id: 'bindings',
label: 'Привязки',
count: bindings.length,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<TabsList variant="line" className="gap-5">
<TabsTrigger
value="overview"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Обзор</span>
</TabsTrigger>
<TabsTrigger
value="dns"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>DNS</span>
</TabsTrigger>
<TabsTrigger
value="availability"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Доступность</span>
</TabsTrigger>
<TabsTrigger
value="subdomains"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Поддомены</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{subdomainRows.length}
</span>
</TabsTrigger>
<TabsTrigger
value="bindings"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Привязки</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{bindings.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
@@ -438,50 +410,43 @@ function DomainOverviewPage() {
</TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4">
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
),
}}
/>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
<DomainBindingsCard bindings={bindings} />
</TabsContent>
</Tabs>
</CountedLineTabs>
<SubdomainEditSheet
mode={sheetMode}
+12 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
@@ -15,6 +15,7 @@ import {
serviceBindingKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { DomainKpiCards } from '@/components/domain-kpi-cards'
import { ResourcePage } from '@/components/reui-kit'
import {
DOMAIN_TABS,
@@ -55,7 +56,9 @@ function DomainsPage() {
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const {
data: domains,
@@ -146,10 +149,18 @@ function DomainsPage() {
return (
<PageShell>
<DomainKpiCards
domains={domains}
activeTab={activeTab}
isLoading={isLoading && !domains}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Домены"
description="Импортированные зоны Cloudflare"
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={domainTabFilter}
filterFields={filterFields}
filters={filters}
+14 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { FolderTreeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -14,6 +14,7 @@ import {
import { api } from '@/lib/api-client'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { GroupKpiCards } from '@/components/group-kpi-cards'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
@@ -66,7 +67,9 @@ function GroupsPage() {
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
const [filters, setFilters] = useState<Filter[]>(() => createDefaultGroupFilters())
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const {
data: groups,
@@ -276,10 +279,19 @@ function GroupsPage() {
/>
)
) : (
<>
<GroupKpiCards
rows={catalogRows}
activeTab={activeTab}
isLoading={false}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Группы"
description="Каталог групп доменов"
tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={groupTabFilter}
filterFields={filterFields}
filters={filters}
@@ -301,6 +313,7 @@ function GroupsPage() {
action: createButton,
}}
/>
</>
)}
<DomainGroupEditSheet
+98 -37
View File
@@ -1,13 +1,14 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FolderTreeIcon, GlobeIcon, HashIcon } from 'lucide-react'
import type { Filter } from '@/components/reui/filters'
import {
domainsListQueryOptions,
groupDetailQueryOptions,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { ResourcePage } from '@/components/reui-kit'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import {
createDefaultGroupDomainFilters,
groupDomainFilterFieldValue,
@@ -52,48 +53,108 @@ function GroupDetailPage() {
const filterFields = useGroupDomainFilterFields()
const columns = useGroupDomainColumns()
const primaryAction = (
const headerActions = (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
К группам
</Button>
)
const metricCards = useMemo(() => {
if (!group) return []
return [
{
id: 'domains',
icon: <GlobeIcon aria-hidden="true" />,
label: 'Домены',
description: 'Зоны в этой группе',
footer: (
<span className="text-foreground text-lg font-semibold tabular-nums">
{group.domain_count}
</span>
),
},
{
id: 'slug',
icon: <HashIcon aria-hidden="true" />,
label: 'Slug',
description: 'Идентификатор группы',
footer: (
<span className="text-foreground font-mono text-sm">{group.slug}</span>
),
},
{
id: 'group',
icon: <FolderTreeIcon aria-hidden="true" />,
label: 'Группа',
description: group.name,
footer: (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={<Link to="/groups" search={{ view: 'board' }} />}
>
Открыть канбан
</Button>
),
},
]
}, [group])
return (
<PageShell>
<ResourcePage
title={group?.name ?? 'Группа'}
description={
group
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
: 'Домены в группе'
}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultGroupDomainFilters())}
getFilterFieldValue={groupDomainFilterFieldValue}
columns={columns}
data={domains ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchGroup()
void refetchDomains()
}}
primaryAction={primaryAction}
emptyState={{
title: 'В группе нет доменов',
description:
'Перетащите домены на канбане групп или назначьте группу при импорте',
action: (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Открыть канбан групп
</Button>
),
}}
/>
<DetailPanel>
<DetailPanel.Header
title={group?.name ?? 'Группа'}
description={
group
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
: 'Домены в группе'
}
actions={headerActions}
/>
{!isLoading && group ? (
<DetailPanel.Metrics cards={metricCards} />
) : null}
<DetailPanel.Section>
<ResourcePage
title="Домены группы"
description="Зоны, назначенные в эту группу"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultGroupDomainFilters())}
getFilterFieldValue={groupDomainFilterFieldValue}
columns={columns}
data={domains ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchGroup()
void refetchDomains()
}}
emptyState={{
title: 'В группе нет доменов',
description:
'Перетащите домены на канбане групп или назначьте группу при импорте',
action: (
<Button
variant="outline"
nativeButton={false}
render={<Link to="/groups" />}
>
Открыть канбан групп
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
</PageShell>
)
}
+14 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -21,6 +21,7 @@ import type {
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { PageShell } from '@/components/page-shell'
import { ServiceKpiCards } from '@/components/service-kpi-cards'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
@@ -146,7 +147,9 @@ function ServicesPage() {
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const handleSelectTab = useCallback((tabId: string) => setActiveTab(tabId), [])
const selection = useServicesSelection()
const {
data,
@@ -621,11 +624,20 @@ function ServicesPage() {
)}
</>
) : (
<>
<ServiceKpiCards
rows={catalogRows}
activeTab={activeTab}
isLoading={false}
onSelectTab={handleSelectTab}
/>
<ServicesGroupedCatalog
data={data ?? { groups: [], ungrouped: [] }}
domainId={domainId}
domainLabel={filteredDomain?.zone_name}
togglingId={togglingServiceId}
activeTab={activeTab}
onTabChange={setActiveTab}
onEditService={setEditingService}
onDeleteService={setDeletingService}
onToggleService={handleServiceToggle}
@@ -644,6 +656,7 @@ function ServicesPage() {
</div>
}
/>
</>
)}
{sheets}
</PageShell>
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import { ChevronDownIcon, LayoutGridIcon, ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared'
@@ -13,6 +14,7 @@ import {
} from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
@@ -20,20 +22,123 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { ServerIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
type SettingsResponse = AppSettingsView & {
id: string
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG
}
type IntegrationStatus = 'connected' | 'available' | 'warning'
const STATUS_META: Record<
IntegrationStatus,
{ label: string; variant: 'success-light' | 'secondary' | 'warning-light' }
> = {
connected: { label: 'Подключено', variant: 'success-light' },
available: { label: 'Доступно', variant: 'secondary' },
warning: { label: 'Требует настройки', variant: 'warning-light' },
}
export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage,
})
function IntegrationLogo({ children }: { children: ReactNode }) {
return (
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
{children}
</ItemMedia>
</Item>
)
}
function IntegrationRow({
id,
name,
description,
logo,
status,
open,
onOpenChange,
children,
}: {
id: string
name: string
description: string
logo: ReactNode
status: IntegrationStatus
open: boolean
onOpenChange: (open: boolean) => void
children: ReactNode
}) {
const meta = STATUS_META[status]
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
<ItemMedia className="translate-y-0! self-center!">
<IntegrationLogo>{logo}</IntegrationLogo>
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{name}</span>
<Badge variant={meta.variant}>{meta.label}</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-1">{description}</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
<CollapsibleTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-controls={`${id}-panel`}
aria-expanded={open}
/>
}
>
Настроить
<ChevronDownIcon
aria-hidden="true"
data-icon="inline-end"
className={cn('transition-transform', open && 'rotate-180')}
/>
</CollapsibleTrigger>
</ItemActions>
</Item>
<CollapsibleContent id={`${id}-panel`}>
<div className="border-border/60 border-t px-3.5 py-4 sm:px-4">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
function SettingsIntegrationsPage() {
const queryClient = useQueryClient()
const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
const [openVpsTracker, setOpenVpsTracker] = useState(true)
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
@@ -50,6 +155,18 @@ function SettingsIntegrationsPage() {
onError: () => toast.error('Не удалось сохранить'),
})
const appSwitcher = data?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG
const appSwitcherStatus: IntegrationStatus =
appSwitcher.apps.length > 0 ? 'connected' : 'available'
const vpsStatus: IntegrationStatus = !data
? 'available'
: data.vpsTrackerSyncEnabled && data.vpsTrackerIntegrationTokenSet
? 'connected'
: data.vpsTrackerUrl || data.vpsTrackerIntegrationTokenSet
? 'warning'
: 'available'
return (
<QueryState
isLoading={isLoading}
@@ -68,25 +185,30 @@ function SettingsIntegrationsPage() {
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<LayoutGridIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</div>
<FrameHeader className="sr-only">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</FrameHeader>
<FramePanel>
<AppSwitcherEditor
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="app-switcher"
name="App Switcher"
description={`${appSwitcher.apps.length} приложений · меню «${appSwitcher.menuLabel}»`}
logo={<LayoutGridIcon aria-hidden="true" />}
status={appSwitcherStatus}
open={openAppSwitcher}
onOpenChange={setOpenAppSwitcher}
>
<AppSwitcherEditor
defaultValues={appSwitcher}
isSaving={saveMut.isPending}
onSave={(next) => saveMut.mutate({ appSwitcher: next })}
/>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>
@@ -100,31 +222,40 @@ function SettingsIntegrationsPage() {
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<ServerIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</div>
<FrameHeader className="sr-only">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</FrameHeader>
<FramePanel>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="vps-tracker"
name="VPS Tracker"
description={
data.vpsTrackerUrl
? data.vpsTrackerUrl
: 'URL, токен и расписание синхронизации'
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
logo={<ServerIcon aria-hidden="true" />}
status={vpsStatus}
open={openVpsTracker}
onOpenChange={setOpenVpsTracker}
>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>