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

This commit is contained in:
Denozordec
2026-06-25 15:47:29 +07:00
parent 3632bd25e4
commit 9569c22e4d
31 changed files with 1125 additions and 547 deletions
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+23 -19
View File
@@ -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>
)
}
+10 -8
View File
@@ -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>
+4 -3
View File
@@ -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}
+42
View File
@@ -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>
)
}