chore: Update package.json and pnpm-lock.yaml to add husky for Git hooks; enhance Docker workflow with pnpm setup and linting steps; refactor various components to utilize new form components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -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 (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<TableCard>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -153,6 +154,6 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Нет привязок</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте привязку на странице сервисов или в таблице поддоменов
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Создайте привязку на странице сервисов или в таблице поддоменов"
|
||||
className="border border-dashed p-4"
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-0">
|
||||
{entries.map(([hostname, hostnameBindings], index) => {
|
||||
|
||||
@@ -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<CreateGroupInput>({
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Группы используются для организации доменов на доске.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="domain-group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="domain-group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Production"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="domain-group-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="domain-group-slug"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="production"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
|
||||
description="Группы используются для организации доменов на доске."
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
contentClassName="gap-6"
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Название"
|
||||
htmlFor="domain-group-name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<Input
|
||||
id="domain-group-name"
|
||||
placeholder="Production"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Slug"
|
||||
htmlFor="domain-group-slug"
|
||||
error={form.formState.errors.slug}
|
||||
>
|
||||
<Input
|
||||
id="domain-group-slug"
|
||||
placeholder="production"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<T extends FieldValues> {
|
||||
name: FieldPath<T>
|
||||
control: Control<T>
|
||||
label: string
|
||||
htmlFor?: string
|
||||
className?: string
|
||||
children: (props: {
|
||||
id: string
|
||||
'aria-invalid': boolean
|
||||
value: unknown
|
||||
onChange: (...args: unknown[]) => void
|
||||
onBlur: () => void
|
||||
ref: React.Ref<unknown>
|
||||
}) => ReactNode
|
||||
}
|
||||
|
||||
export function FormField<T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldProps<T>) {
|
||||
const fieldId = htmlFor ?? String(name)
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={!!fieldState.error}
|
||||
className={cn(className)}
|
||||
>
|
||||
<FieldLabel htmlFor={fieldId}>{label}</FieldLabel>
|
||||
{children({
|
||||
id: fieldId,
|
||||
'aria-invalid': !!fieldState.error,
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
onBlur: field.onBlur,
|
||||
ref: field.ref,
|
||||
})}
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface FormFieldSimpleProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
error?: { message?: string }
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormFieldSimple({
|
||||
label,
|
||||
htmlFor,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldSimpleProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error} className={cn(className)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
<FieldError errors={[error]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -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<T extends FieldValues> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
form: UseFormReturn<T>
|
||||
onSubmit: (values: T) => void | Promise<void>
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function FormSheet<T extends FieldValues>({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
onSubmit,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
}: FormSheetProps<T>) {
|
||||
const handleSubmit = form.handleSubmit(onSubmit)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className={cn(className)}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description && <SheetDescription>{description}</SheetDescription>}
|
||||
</SheetHeader>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn('flex flex-1 flex-col gap-4 px-4', contentClassName)}
|
||||
>
|
||||
{children}
|
||||
{footer && <SheetFooter>{footer}</SheetFooter>}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<typeof Button> {
|
||||
isLoading?: boolean
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
export function LoadingButton({
|
||||
isLoading = false,
|
||||
loadingLabel,
|
||||
children,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: LoadingButtonProps) {
|
||||
const label =
|
||||
isLoading && loadingLabel != null ? loadingLabel : children
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled ?? isLoading}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Spinner data-icon="inline-start" />}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">Имя пользователя</FieldLabel>
|
||||
<FormFieldSimple
|
||||
label="Имя пользователя"
|
||||
htmlFor="username"
|
||||
error={form.formState.errors.username}
|
||||
>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="admin"
|
||||
@@ -68,9 +67,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
{...form.register('username')}
|
||||
aria-invalid={!!form.formState.errors.username}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Пароль"
|
||||
htmlFor="password"
|
||||
error={form.formState.errors.password}
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -78,7 +80,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
{...form.register('password')}
|
||||
aria-invalid={!!form.formState.errors.password}
|
||||
/>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
{rootError && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
@@ -86,12 +88,14 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
<AlertDescription>{rootError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Spinner data-icon="inline-start" />}
|
||||
{isLoading ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</Field>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isLoading}
|
||||
loadingLabel="Вход…"
|
||||
>
|
||||
Войти
|
||||
</LoadingButton>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export function SectionCardsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<div className="flex flex-col gap-2 px-6 pb-6">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
isLoading={isDeleting}
|
||||
loadingLabel="Удаление…"
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
}
|
||||
title="Удалить сервис?"
|
||||
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="button"
|
||||
className="ml-auto"
|
||||
disabled={!canSubmit || isSaving || isDeleting}
|
||||
disabled={!canSubmit || isDeleting}
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isCreate ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -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<typeof createServiceGroupSchema>
|
||||
|
||||
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<CreateServiceGroupInput['type']>('custom')
|
||||
const [domain, setDomain] = useState('')
|
||||
const form = useForm<ServiceGroupFormValues>({
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="group-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="VPN"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-type">Тип</FieldLabel>
|
||||
<FormSheet<ServiceGroupFormValues>
|
||||
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={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Название"
|
||||
htmlFor="group-name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="VPN"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Тип" htmlFor="group-type">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={type}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
setType(value as CreateServiceGroupInput['type'])
|
||||
field.onChange(value as CreateServiceGroupInput['type'])
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="group-type">
|
||||
@@ -125,25 +136,20 @@ export function ServiceGroupEditSheet({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-domain">Домен группы (FQDN, необязательно)</FieldLabel>
|
||||
<Input
|
||||
id="group-domain"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="domain.new.ivx.su"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Домен группы (FQDN, необязательно)"
|
||||
htmlFor="group-domain"
|
||||
>
|
||||
<Input
|
||||
id="group-domain"
|
||||
placeholder="domain.new.ivx.su"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_name">Имя</FieldLabel>
|
||||
<FormFieldSimple label="Имя" htmlFor="subdomain_name">
|
||||
<Input
|
||||
id="subdomain_name"
|
||||
placeholder="www"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="font-mono"
|
||||
aria-invalid={!name.trim() && name.length > 0}
|
||||
/>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_service">Сервис</FieldLabel>
|
||||
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
|
||||
<Select
|
||||
items={serviceItems}
|
||||
value={serviceId}
|
||||
@@ -151,11 +145,11 @@ export function SubdomainEditSheet({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subdomain_cert_monitoring">
|
||||
Мониторинг SSL
|
||||
</FieldLabel>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Мониторинг SSL"
|
||||
htmlFor="subdomain_cert_monitoring"
|
||||
>
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={certMonitoring}
|
||||
@@ -180,19 +174,20 @@ export function SubdomainEditSheet({
|
||||
?.description
|
||||
}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FormFieldSimple>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={isSaving || !name.trim()} className="w-full">
|
||||
{isSaving && <Spinner data-icon="inline-start" />}
|
||||
{isSaving
|
||||
? 'Сохранение…'
|
||||
: mode === 'create'
|
||||
? 'Создать'
|
||||
: 'Сохранить'}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!name.trim()}
|
||||
isLoading={isSaving}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
{mode === 'create' ? 'Создать' : 'Сохранить'}
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TableCard } from '@/components/table-card'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||
import { formatDate } from '@/lib/format'
|
||||
@@ -46,7 +47,7 @@ export function SubdomainsTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<TableCard>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -61,7 +62,7 @@ export function SubdomainsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.subdomain.id}>
|
||||
<TableRow key={row.subdomain.id} className="h-10 text-sm">
|
||||
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
||||
@@ -115,7 +116,7 @@ export function SubdomainsTable({
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCard>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
interface TableSkeletonProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
withCard?: boolean
|
||||
}
|
||||
|
||||
function TableRowsSkeleton({ rows, columns }: { rows: number; columns: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="flex gap-4 border-b px-4 py-3">
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-4 border-b px-4 py-3 last:border-0">
|
||||
{Array.from({ length: columns }).map((__, colIndex) => (
|
||||
<Skeleton key={colIndex} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
withCard = true,
|
||||
}: TableSkeletonProps) {
|
||||
const table = <TableRowsSkeleton rows={rows} columns={columns} />
|
||||
|
||||
if (!withCard) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">{table}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{table}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user