diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 1a96f11..55ee8d3 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 44284, + "pid": 42864, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925", - "startedAt": 1782094135868 + "startedAt": 1782359415038 } diff --git a/.cursor/rules/frontend-shadcn.mdc b/.cursor/rules/frontend-shadcn.mdc index 6b3129b..b44dae5 100644 --- a/.cursor/rules/frontend-shadcn.mdc +++ b/.cursor/rules/frontend-shadcn.mdc @@ -22,11 +22,12 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl ## Шаг 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 ` — API и примеры -4. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/"` — если MCP не дал результат -5. Только потом писать код +3. `cd apps/web && pnpm dlx shadcn@latest docs ` — сверить API с [Components](https://ui.shadcn.com/docs/components) +4. Сверить MCP examples ↔ docs API — только потом писать код +5. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/"` — если MCP не дал результат **Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add `. @@ -49,6 +50,12 @@ Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workfl | `DataTableCard` | `data-table-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` | `section-cards-skeleton.tsx` | +| `TableSkeleton` | `table-skeleton.tsx` | **Overlay:** Sheet — forms; AlertDialog — destructive confirm. diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index 9c4dcd9..d259ada 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -10,12 +10,14 @@ alwaysApply: false ## Docs workflow (обязательно) +0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты 1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection 2. MCP `plugin-shadcn-shadcn` — search → examples → add command -3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` → fetch URLs -4. Код по examples + docs API +3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` → сверить API с [Components](https://ui.shadcn.com/docs/components) +4. Код по examples + docs API (только после совпадения MCP ↔ docs) 5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives) 6. MCP `get_audit_checklist` — перед merge +7. Codegraph `codegraph_status` — Pending sync пустой ## Иерархия компонентов @@ -30,6 +32,12 @@ apps/web/src/components/ ← shared + domain + layout data-table-card.tsx section-cards.tsx status-badge.tsx + form-sheet.tsx ← Sheet + RHF FormProvider + form-field.tsx ← Field + Controller + aria-invalid + table-card.tsx ← Card + Table wrapper + loading-button.tsx ← Button + Spinner + label swap + section-cards-skeleton.tsx + table-skeleton.tsx layout/ ← app-shell, site-header domain-* ← бизнес-компоненты ``` @@ -45,7 +53,10 @@ apps/web/src/components/ ← shared + domain + layout | Empty | `EmptyState` | `Empty` | | Loading / Error | `QueryState` | `Skeleton`, `Alert` | | Status | `StatusBadge` | `Badge` | -| Create/Edit | `*-sheet.tsx` | `Sheet`, `Field` | +| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | +| Form field | `FormField` | `Field`, `Input`, `Select` | +| Submit button | `LoadingButton` | `Button`, `Spinner` | +| Table wrapper | `TableCard` | `Table`, `Card` | | Delete confirm | `ConfirmDialog` | `AlertDialog` | | List row | — | `Item variant="outline" size="sm"` | | Nav | `AppSidebar` | `Sidebar` | @@ -80,6 +91,18 @@ Toolbar: gap-2 **Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner. +## UX/UI 2026 (состояния данных) + +Каждый блок: **default, hover, focus, disabled, empty, loading, error**. + +- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице +- **Empty** — `EmptyState` с CTA (кнопка создания) +- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать») +- **Error** — `QueryState` + `onRetry` + иконка + текст +- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел +- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран +- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса + ## Button hierarchy (max 1 primary per screen) 1. `default` — главный CTA diff --git a/.cursor/rules/shadcn-mcp.mdc b/.cursor/rules/shadcn-mcp.mdc index 8af2516..0e990e9 100644 --- a/.cursor/rules/shadcn-mcp.mdc +++ b/.cursor/rules/shadcn-mcp.mdc @@ -11,15 +11,18 @@ CLI и docs — **после** MCP, по команде из `get_add_command_fo ## Порядок (строго) +0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками) 1. **`get_project_registries`** — какие registry доступны в проекте 2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`) 3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX 4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...` 5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc)) -6. Адаптировать пример под TanStack Router / Query → `apps/web/src/` -7. **CLI docs:** `cd apps/web && pnpm dlx shadcn@latest docs ` — fetch URLs, сверить API -8. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–7 для примитивов) -9. **`get_audit_checklist`** — перед merge PR +6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs ` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) +7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения +8. Адаптировать пример под TanStack Router / Query → `apps/web/src/` +9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов) +10. **`get_audit_checklist`** — перед merge PR +11. **Codegraph** `codegraph_status` — Pending sync пустой после правок ## Когда вызывать MCP diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index a9d7a52..1d26a21 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -16,6 +16,23 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.12.1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint web + run: pnpm --filter web lint + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..a97da46 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +pnpm --filter web lint diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index ef614d2..96da70d 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -5,6 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' +const rawHtmlElements = ['table', 'select', 'hr'] + export default defineConfig([ globalIgnores(['dist']), { @@ -18,5 +20,64 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + 'react-hooks/set-state-in-effect': 'off', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/components/ui/*'], + message: 'Импортируйте примитивы через @cfdm/ui/components/*', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + ...rawHtmlElements.map((name) => ({ + selector: `JSXOpeningElement[name.name="${name}"]`, + message: `Используйте shadcn-компонент вместо <${name}>`, + })), + { + selector: + 'JSXAttribute[name.name="className"] Literal[value=/\\bspace-[xy]-/]', + message: 'Используйте flex + gap-* вместо space-y-* / space-x-*', + }, + { + selector: + 'JSXAttribute[name.name="className"] Literal[value=/\\bbg-(emerald|red|green|blue|yellow|orange)-/]', + message: 'Используйте semantic tokens (bg-primary, bg-muted и т.д.)', + }, + { + selector: + 'JSXAttribute[name.name="className"] Literal[value=/\\btext-(blue|red|green)-/]', + message: 'Используйте semantic tokens (text-foreground, text-muted-foreground)', + }, + ], + }, + }, + { + files: ['src/routes/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: ['src/components/tagged-input.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: [ + '**/services-board/*-row.tsx', + '**/groups-board/*-row.tsx', + '**/layout/app-shell.tsx', + '**/service-binding-card.tsx', + ], + rules: { + 'no-restricted-syntax': 'off', + }, }, ]) diff --git a/apps/web/src/components/dns-records-table.tsx b/apps/web/src/components/dns-records-table.tsx index 530df53..d91ed8a 100644 --- a/apps/web/src/components/dns-records-table.tsx +++ b/apps/web/src/components/dns-records-table.tsx @@ -1,5 +1,6 @@ import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' +import { TableCard } from '@/components/table-card' import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping' import type { DnsRecord } from '@/lib/schemas' import { Badge } from '@cfdm/ui/components/badge' @@ -119,7 +120,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab const groups = groupDnsRecords(records) return ( -
+ @@ -153,6 +154,6 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab )}
-
+ ) } diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index 886622c..a62e0de 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -1,4 +1,6 @@ import { Link } from '@tanstack/react-router' +import { Link2Icon } from 'lucide-react' +import { EmptyState } from '@/components/empty-state' import { StatusBadge } from '@/components/status-badge' import { groupBindingsByHostname } from '@/lib/domain-ips' import type { ServiceBinding } from '@/lib/schemas' @@ -12,12 +14,6 @@ import { CardHeader, CardTitle, } from '@cfdm/ui/components/card' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from '@cfdm/ui/components/empty' import { Item, ItemActions, @@ -47,14 +43,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { {entries.length === 0 ? ( - - - Нет привязок - - Создайте привязку на странице сервисов или в таблице поддоменов - - - + ) : ( {entries.map(([hostname, hostnameBindings], index) => { diff --git a/apps/web/src/components/domain-group-edit-sheet.tsx b/apps/web/src/components/domain-group-edit-sheet.tsx index ce95253..f7de785 100644 --- a/apps/web/src/components/domain-group-edit-sheet.tsx +++ b/apps/web/src/components/domain-group-edit-sheet.tsx @@ -1,21 +1,12 @@ -import { useEffect, useState } from 'react' -import type { CreateGroupInput, Group } from '@/lib/schemas' -import { Button } from '@cfdm/ui/components/button' -import { - Field, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { createGroupSchema, type CreateGroupInput, type Group } from '@/lib/schemas' +import { FormSheet } from '@/components/form-sheet' +import { FormFieldSimple } from '@/components/form-field' +import { LoadingButton } from '@/components/loading-button' +import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' -import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from '@cfdm/ui/components/sheet' -import { Spinner } from '@cfdm/ui/components/spinner' interface DomainGroupEditSheetProps { mode: 'create' | 'edit' @@ -36,76 +27,74 @@ export function DomainGroupEditSheet({ onCreate, onSave, }: DomainGroupEditSheetProps) { - const [name, setName] = useState('') - const [slug, setSlug] = useState('') + const form = useForm({ + resolver: zodResolver(createGroupSchema), + defaultValues: { name: '', slug: '' }, + }) useEffect(() => { if (!open) return if (mode === 'edit' && group) { - setName(group.name) - setSlug(group.slug) + form.reset({ name: group.name, slug: group.slug }) } else { - setName('') - setSlug('') + form.reset({ name: '', slug: '' }) } - }, [open, mode, group]) + }, [open, mode, group, form]) - function handleSubmit(event: React.FormEvent) { - event.preventDefault() - const trimmedName = name.trim() - const trimmedSlug = slug.trim() - if (!trimmedName || !trimmedSlug) return - const body: CreateGroupInput = { - name: trimmedName, - slug: trimmedSlug, - } + function handleSubmit(values: CreateGroupInput) { if (mode === 'create') { - onCreate?.(body) + onCreate?.(values) } else if (group) { - onSave?.(group.id, body) + onSave?.(group.id, values) } } return ( - - - - - {mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'} - - - Группы используются для организации доменов на доске. - - -
- - - Название - setName(event.target.value)} - placeholder="Production" - /> - - - Slug - setSlug(event.target.value)} - placeholder="production" - /> - - - - - -
-
-
+ + {mode === 'create' ? 'Создать' : 'Сохранить'} + + } + > + + + + + + + + + ) } diff --git a/apps/web/src/components/form-field.tsx b/apps/web/src/components/form-field.tsx new file mode 100644 index 0000000..7fb6dc7 --- /dev/null +++ b/apps/web/src/components/form-field.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react' +import type { Control, FieldPath, FieldValues } from 'react-hook-form' +import { Controller } from 'react-hook-form' +import { + Field, + FieldError, + FieldLabel, +} from '@cfdm/ui/components/field' +import { cn } from '@cfdm/ui/lib/utils' + +interface FormFieldProps { + name: FieldPath + control: Control + label: string + htmlFor?: string + className?: string + children: (props: { + id: string + 'aria-invalid': boolean + value: unknown + onChange: (...args: unknown[]) => void + onBlur: () => void + ref: React.Ref + }) => ReactNode +} + +export function FormField({ + name, + control, + label, + htmlFor, + className, + children, +}: FormFieldProps) { + const fieldId = htmlFor ?? String(name) + + return ( + ( + + {label} + {children({ + id: fieldId, + 'aria-invalid': !!fieldState.error, + value: field.value, + onChange: field.onChange, + onBlur: field.onBlur, + ref: field.ref, + })} + + + )} + /> + ) +} + +interface FormFieldSimpleProps { + label: string + htmlFor: string + error?: { message?: string } + className?: string + children: ReactNode +} + +export function FormFieldSimple({ + label, + htmlFor, + error, + className, + children, +}: FormFieldSimpleProps) { + return ( + + {label} + {children} + + + ) +} diff --git a/apps/web/src/components/form-sheet.tsx b/apps/web/src/components/form-sheet.tsx new file mode 100644 index 0000000..b9687ca --- /dev/null +++ b/apps/web/src/components/form-sheet.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import type { FieldValues, UseFormReturn } from 'react-hook-form' +import { FormProvider } from 'react-hook-form' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { cn } from '@cfdm/ui/lib/utils' + +interface FormSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + form: UseFormReturn + onSubmit: (values: T) => void | Promise + children: ReactNode + footer?: ReactNode + className?: string + contentClassName?: string +} + +export function FormSheet({ + open, + onOpenChange, + title, + description, + form, + onSubmit, + children, + footer, + className, + contentClassName, +}: FormSheetProps) { + const handleSubmit = form.handleSubmit(onSubmit) + + return ( + + + + {title} + {description && {description}} + + +
+ {children} + {footer && {footer}} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/groups-board/domain-row.tsx b/apps/web/src/components/groups-board/domain-row.tsx index 2e3df6f..6c8a9e4 100644 --- a/apps/web/src/components/groups-board/domain-row.tsx +++ b/apps/web/src/components/groups-board/domain-row.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { useDraggable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' import { Link } from '@tanstack/react-router' @@ -67,7 +68,7 @@ function DomainServiceList({ ) } -export function DomainRow({ +export const DomainRow = memo(function DomainRow({ domain, serviceLabels = [], dragDisabled = false, @@ -174,4 +175,4 @@ export function DomainRow({ ) -} +}) diff --git a/apps/web/src/components/loading-button.tsx b/apps/web/src/components/loading-button.tsx new file mode 100644 index 0000000..aa9ae72 --- /dev/null +++ b/apps/web/src/components/loading-button.tsx @@ -0,0 +1,32 @@ +import type { ComponentProps } from 'react' +import { Button } from '@cfdm/ui/components/button' +import { Spinner } from '@cfdm/ui/components/spinner' +import { cn } from '@cfdm/ui/lib/utils' + +interface LoadingButtonProps extends ComponentProps { + isLoading?: boolean + loadingLabel?: string +} + +export function LoadingButton({ + isLoading = false, + loadingLabel, + children, + disabled, + className, + ...props +}: LoadingButtonProps) { + const label = + isLoading && loadingLabel != null ? loadingLabel : children + + return ( + + ) +} diff --git a/apps/web/src/components/login-form.tsx b/apps/web/src/components/login-form.tsx index bab358e..a26f476 100644 --- a/apps/web/src/components/login-form.tsx +++ b/apps/web/src/components/login-form.tsx @@ -6,7 +6,8 @@ import { cn } from '@cfdm/ui/lib/utils' import { api } from '@/lib/api-client' import { setToken } from '@/lib/auth' import { loginSchema, type LoginInput } from '@/lib/schemas' -import { Button } from '@cfdm/ui/components/button' +import { FormFieldSimple } from '@/components/form-field' +import { LoadingButton } from '@/components/loading-button' import { Card, CardContent, @@ -14,14 +15,9 @@ import { CardHeader, CardTitle, } from '@cfdm/ui/components/card' -import { - Field, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' +import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' -import { Spinner } from '@cfdm/ui/components/spinner' type LoginFormProps = React.ComponentProps<'div'> @@ -59,8 +55,11 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
- - Имя пользователя + - - - Пароль + + - + {rootError && ( @@ -86,12 +88,14 @@ export function LoginForm({ className, ...props }: LoginFormProps) { {rootError} )} - - - + + Войти +
diff --git a/apps/web/src/components/section-cards-skeleton.tsx b/apps/web/src/components/section-cards-skeleton.tsx new file mode 100644 index 0000000..c65fca5 --- /dev/null +++ b/apps/web/src/components/section-cards-skeleton.tsx @@ -0,0 +1,37 @@ +import { + Card, + CardFooter, + CardHeader, +} from '@cfdm/ui/components/card' +import { Skeleton } from '@cfdm/ui/components/skeleton' + +export function SectionCardsSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + + + + + + + + + ))} +
+ + + + + +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index fceb392..74aeeed 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -34,7 +34,7 @@ import { SheetHeader, SheetTitle, } from '@cfdm/ui/components/sheet' -import { Spinner } from '@cfdm/ui/components/spinner' +import { LoadingButton } from '@/components/loading-button' import { Tabs, TabsContent, @@ -432,30 +432,32 @@ export function ServiceEditSheet({ {!isCreate ? ( - {isDeleting && } Удалить - + } title="Удалить сервис?" description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо." onConfirm={handleDelete} /> ) : null} - + diff --git a/apps/web/src/components/service-group-edit-sheet.tsx b/apps/web/src/components/service-group-edit-sheet.tsx index 0148365..38f02fa 100644 --- a/apps/web/src/components/service-group-edit-sheet.tsx +++ b/apps/web/src/components/service-group-edit-sheet.tsx @@ -1,11 +1,16 @@ -import { useEffect, useState } from 'react' -import type { CreateServiceGroupInput, ServiceGroup } from '@/lib/schemas' -import { Button } from '@cfdm/ui/components/button' +import { useEffect } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' import { - Field, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' + createServiceGroupSchema, + type CreateServiceGroupInput, + type ServiceGroup, +} from '@/lib/schemas' +import type { z } from 'zod' +import { FormSheet } from '@/components/form-sheet' +import { FormFieldSimple } from '@/components/form-field' +import { LoadingButton } from '@/components/loading-button' +import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' import { Select, @@ -14,15 +19,6 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' -import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from '@cfdm/ui/components/sheet' -import { Spinner } from '@cfdm/ui/components/spinner' const groupTypes = [ { value: 'vpn', label: 'VPN' }, @@ -32,6 +28,8 @@ const groupTypes = [ { value: 'custom', label: 'Другое' }, ] as const +type ServiceGroupFormValues = z.input + interface ServiceGroupEditSheetProps { mode: 'create' | 'edit' group: ServiceGroup | null @@ -51,31 +49,30 @@ export function ServiceGroupEditSheet({ onCreate, onSave, }: ServiceGroupEditSheetProps) { - const [name, setName] = useState('') - const [type, setType] = useState('custom') - const [domain, setDomain] = useState('') + const form = useForm({ + resolver: zodResolver(createServiceGroupSchema), + defaultValues: { name: '', type: 'custom', domain: null }, + }) useEffect(() => { if (!open) return if (mode === 'edit' && group) { - setName(group.name) - setType(group.type) - setDomain(group.domain ?? '') + form.reset({ + name: group.name, + type: group.type, + domain: group.domain ?? null, + }) } else { - setName('') - setType('custom') - setDomain('') + form.reset({ name: '', type: 'custom', domain: null }) } - }, [open, mode, group]) + }, [open, mode, group, form]) - function handleSubmit(event: React.FormEvent) { - event.preventDefault() - const trimmedName = name.trim() - if (!trimmedName) return + function handleSubmit(values: ServiceGroupFormValues) { const body: CreateServiceGroupInput = { - name: trimmedName, - type, - domain: domain.trim() || null, + name: values.name, + type: values.type ?? 'custom', + icon: values.icon, + domain: values.domain?.trim() || null, } if (mode === 'create') { onCreate?.(body) @@ -85,33 +82,47 @@ export function ServiceGroupEditSheet({ } return ( - - - - - {mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'} - - - Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов. - - -
- - - Название - setName(event.target.value)} - placeholder="VPN" - /> - - - Тип + + open={open} + onOpenChange={onOpenChange} + title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'} + description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов." + form={form} + onSubmit={handleSubmit} + contentClassName="gap-6" + footer={ + + {mode === 'create' ? 'Создать' : 'Сохранить'} + + } + > + + + + + + ( - - - Домен группы (FQDN, необязательно) - setDomain(event.target.value)} - placeholder="domain.new.ivx.su" - /> - - - - - -
-
-
+ )} + /> + + + + + + ) } diff --git a/apps/web/src/components/services-board/service-row.tsx b/apps/web/src/components/services-board/service-row.tsx index 0193b61..bd0c787 100644 --- a/apps/web/src/components/services-board/service-row.tsx +++ b/apps/web/src/components/services-board/service-row.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' @@ -41,7 +42,7 @@ export interface ServiceRowProps { showCheckbox?: boolean } -export function ServiceRow({ +export const ServiceRow = memo(function ServiceRow({ service, onToggle, onEdit, @@ -196,4 +197,4 @@ export function ServiceRow({ ) -} +}) diff --git a/apps/web/src/components/subdomain-edit-sheet.tsx b/apps/web/src/components/subdomain-edit-sheet.tsx index c9b551e..05cde3f 100644 --- a/apps/web/src/components/subdomain-edit-sheet.tsx +++ b/apps/web/src/components/subdomain-edit-sheet.tsx @@ -3,14 +3,10 @@ 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 { Button } from '@cfdm/ui/components/button' +import { FormFieldSimple } from '@/components/form-field' +import { LoadingButton } from '@/components/loading-button' import { Input } from '@cfdm/ui/components/input' -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' +import { FieldDescription, FieldGroup } from '@cfdm/ui/components/field' import { Select, SelectContent, @@ -26,7 +22,6 @@ import { SheetHeader, SheetTitle, } from '@cfdm/ui/components/sheet' -import { Spinner } from '@cfdm/ui/components/spinner' export interface SubdomainEditValues { name: string @@ -121,20 +116,19 @@ export function SubdomainEditSheet({
- - Имя + setName(event.target.value)} className="font-mono" + aria-invalid={!name.trim() && name.length > 0} /> - + {mode === 'edit' && ( <> - - Сервис + - - - - Мониторинг SSL - + + + + + + + {dnsTypeItems.map((item) => ( + + {item.label} + + ))} + + + )} /> - - )} - - - - - - Новая DNS-запись - - Добавить запись в зону {domain?.zone_name ?? ''} - - - - - - Тип - ( - - )} + + + + + + + + + + + + Прокси Cloudflare + ( + - - - Имя - - - - Значение - - - - TTL - - - - Прокси Cloudflare - ( - - )} - /> - - - - - -
- - + )} + /> + + + ) } diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index 671fa14..c87e8a6 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -33,8 +33,8 @@ import { CardHeader, CardTitle, } from '@cfdm/ui/components/card' -import { Skeleton } from '@cfdm/ui/components/skeleton' -import { Spinner } from '@cfdm/ui/components/spinner' +import { LoadingButton } from '@/components/loading-button' +import { TableSkeleton } from '@/components/table-skeleton' export const Route = createFileRoute('/_auth/domains/$domainId/')({ loader: async ({ context: { queryClient }, params }) => { @@ -54,12 +54,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({ function DomainPageSkeleton() { return (
- +
- - + +
- +
) } @@ -166,16 +166,14 @@ function DomainOverviewPage() { description="Обзор домена и поддоменов" actions={ <> - + Синхронизировать + - - - - + ) : ( + + ) + } + > + {!isTrulyEmpty && !isZeroResults && ( + + )} + + + + + Импортировать + + } + > + + + + + + + + + ) } diff --git a/apps/web/src/routes/_auth/groups/$groupId.tsx b/apps/web/src/routes/_auth/groups/$groupId.tsx index bdd0f4c..f915dcc 100644 --- a/apps/web/src/routes/_auth/groups/$groupId.tsx +++ b/apps/web/src/routes/_auth/groups/$groupId.tsx @@ -8,8 +8,10 @@ import { } from '@/queries' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' +import { QueryState } from '@/components/query-state' import { StatusBadge } from '@/components/status-badge' import { DataTableCard } from '@/components/data-table-card' +import { TableSkeleton } from '@/components/table-skeleton' import { TableToolbar } from '@/components/table-toolbar' import { Button } from '@cfdm/ui/components/button' import { @@ -35,8 +37,24 @@ function GroupDetailPage() { const [searchQuery, setSearchQuery] = useState('') const { groupId } = Route.useParams() const id = Number(groupId) - const { data: group } = useQuery(groupDetailQueryOptions(id)) - const { data: domains } = useQuery(domainsListQueryOptions(id)) + const { + data: group, + isLoading: groupLoading, + isError: groupError, + error: groupErr, + refetch: refetchGroup, + } = useQuery(groupDetailQueryOptions(id)) + const { + data: domains, + isLoading: domainsLoading, + isError: domainsError, + error: domainsErr, + refetch: refetchDomains, + } = useQuery(domainsListQueryOptions(id)) + + const isLoading = groupLoading || domainsLoading + const isError = groupError || domainsError + const error = groupErr ?? domainsErr const filteredDomains = useMemo(() => { const list = domains ?? [] @@ -46,6 +64,7 @@ function GroupDetailPage() { }, [domains, searchQuery]) const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0 + const hasRecords = filteredDomains.length > 0 return ( @@ -59,82 +78,113 @@ function GroupDetailPage() { actions={ } /> - - } + { + void refetchGroup() + void refetchDomains() + }} + skeleton={} > - - - - Зона - Статус - Сервисы - Действия - - - - {filteredDomains.map((domain) => ( - - {domain.zone_name} - - - - - - - - - - - ))} - -
-
+ setSearchQuery('')}> + Сбросить фильтр + + ) : ( + + ) + } + toolbar={ + + } + > + {hasRecords && ( + + + + Зона + Статус + Сервисы + Действия + + + + {filteredDomains.map((domain) => ( + + {domain.zone_name} + + + + + + + + + + + ))} + +
+ )} +
+
) } diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 1f54d7d..f0aa24a 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -5,6 +5,7 @@ import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' import { SectionCards } from '@/components/section-cards' +import { SectionCardsSkeleton } from '@/components/section-cards-skeleton' export const Route = createFileRoute('/_auth/')({ loader: ({ context: { queryClient } }) => @@ -54,6 +55,7 @@ function DashboardPage() { isLoading={isLoading} isError={isError} error={error} + skeleton={} onRetry={() => { void refetchDomains() void refetchSummary() diff --git a/package.json b/package.json index 92566b2..7fd54ed 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "dev": "turbo run dev", "build": "turbo run build", "test": "turbo run test", - "lint": "turbo run lint" + "lint": "turbo run lint", + "prepare": "husky" }, "devDependencies": { + "husky": "^9.1.7", "turbo": "^2.5.4" }, "packageManager": "pnpm@10.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df45c08..528dc01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ importers: .: devDependencies: + husky: + specifier: ^9.1.7 + version: 9.1.7 turbo: specifier: ^2.5.4 version: 2.9.18 @@ -2908,6 +2911,11 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -6313,6 +6321,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + husky@9.1.7: {} + ieee754@1.2.1: {} ignore@5.3.2: {}