Compare commits
10
Commits
62be7af0c3
...
6c379f339b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c379f339b | ||
|
|
c6fa3428e6 | ||
|
|
a4fd955c1b | ||
|
|
52edc3ba73 | ||
|
|
57ee231417 | ||
|
|
c3ad676e75 | ||
|
|
a15b52df95 | ||
|
|
1e8236298d | ||
|
|
af191dcd5b | ||
|
|
b4daa76696 |
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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`
|
||||
@@ -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=
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user