Enhance frontend documentation and UI components; update frontend-shadcn.mdc to include UI patterns reference; improve navigation structure in AppSidebar by categorizing items; refactor DataTableCard to support empty states with EmptyState component; implement ConfirmDialog for delete actions in DnsRecordsTable and GroupsPage; add search functionality in CertificatesPage and GroupsPage for better user experience; update ServiceEditSheet to utilize tabs for organization.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec
2026-06-19 12:15:29 +07:00
parent d11414666f
commit 4a70cf5854
32 changed files with 1546 additions and 435 deletions
+86 -38
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
domainKeys,
@@ -20,6 +21,9 @@ import type {
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
@@ -31,17 +35,16 @@ import {
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({
domainId:
search.domainId != null && search.domainId !== ''
? Number(search.domainId)
: undefined,
}),
loader: ({ context: { queryClient } }) =>
Promise.all([
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
@@ -88,7 +91,13 @@ function setGroupEnabled(
}
}
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
function ServicesPage() {
const { domainId } = Route.useSearch()
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
@@ -98,9 +107,19 @@ function ServicesPage() {
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const queryClient = useQueryClient()
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
const {
data,
isLoading,
isError,
error,
refetch,
} = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const filteredDomain = domainId
? domains?.find((d) => d.id === domainId)
: undefined
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
@@ -286,15 +305,34 @@ function ServicesPage() {
toggleGroupMutation.mutate({ id: groupId, enabled })
}
const groups = data?.groups ?? []
const ungrouped = data?.ungrouped ?? []
const groups = useMemo(() => {
const list = data?.groups ?? []
if (!domainId) return list
return list
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
}, [data?.groups, domainId])
const ungrouped = useMemo(() => {
const list = data?.ungrouped ?? []
if (!domainId) return list
return list.filter((s) => serviceMatchesDomain(s, domainId))
}, [data?.ungrouped, domainId])
const isEmpty = groups.length === 0 && ungrouped.length === 0
return (
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageShell>
<PageHeader
title="Сервисы"
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
@@ -305,28 +343,37 @@ function ServicesPage() {
}
/>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="size-8" />
</div>
) : isEmpty ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Сервисы не найдены</EmptyTitle>
<EmptyDescription>
Создайте группу и сервис — например VPN Panel или Home Assistant.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
</EmptyContent>
</Empty>
) : (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
}
>
{isEmpty ? (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
</div>
}
/>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<ServiceGroupCard
@@ -362,7 +409,8 @@ function ServicesPage() {
</Card>
) : null}
</div>
)}
)}
</QueryState>
<ServiceEditSheet
mode="edit"
@@ -409,6 +457,6 @@ function ServicesPage() {
}}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/>
</div>
</PageShell>
)
}