Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards. Co-authored-by: Cursor <[email protected]>
277 lines
8.0 KiB
TypeScript
277 lines
8.0 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { Link } from '@tanstack/react-router'
|
|
import { GlobeIcon, MoreHorizontalIcon } from 'lucide-react'
|
|
|
|
import { Badge } from '@cfdm/ui/components/badge'
|
|
import { Button } from '@cfdm/ui/components/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@cfdm/ui/components/dropdown-menu'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@cfdm/ui/components/select'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import {
|
|
DataGridCard,
|
|
columnDefFromDataGrid,
|
|
} from '@/components/data-grid-card'
|
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
|
import { DomainsFiltersToolbar } from '@/components/domains-filters-toolbar'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import type { DomainListItem } from '@/lib/schemas'
|
|
import { useState } from 'react'
|
|
|
|
export type DomainTableRow = DomainListItem
|
|
|
|
interface GroupFilterItem {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
export interface DomainsDataGridProps {
|
|
data: DomainTableRow[]
|
|
totalCount: number
|
|
groupFilterItems: GroupFilterItem[]
|
|
groupFilterValue: string
|
|
onGroupFilterChange: (value: string | null) => void
|
|
search: string
|
|
onSearchChange: (value: string) => void
|
|
onDelete: (domain: DomainTableRow) => void
|
|
isDeleting?: boolean
|
|
emptyAction?: React.ReactNode
|
|
}
|
|
|
|
export function DomainsDataGrid({
|
|
data,
|
|
totalCount,
|
|
groupFilterItems,
|
|
groupFilterValue,
|
|
onGroupFilterChange,
|
|
search,
|
|
onSearchChange,
|
|
onDelete,
|
|
isDeleting = false,
|
|
emptyAction,
|
|
}: DomainsDataGridProps) {
|
|
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
|
|
|
const filteredData = useMemo(() => {
|
|
const query = search.trim().toLowerCase()
|
|
if (!query) return data
|
|
return data.filter(
|
|
(d) =>
|
|
d.zone_name.toLowerCase().includes(query) ||
|
|
(d.group_name ?? '').toLowerCase().includes(query),
|
|
)
|
|
}, [data, search])
|
|
|
|
const columns = useMemo<DataGridColumn<DomainTableRow>[]>(
|
|
() => [
|
|
{
|
|
key: 'zone_name',
|
|
header: 'Зона',
|
|
icon: GlobeIcon,
|
|
sortable: true,
|
|
sortValue: (row) => row.zone_name,
|
|
cell: (row) => (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 font-medium"
|
|
nativeButton={false}
|
|
render={
|
|
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
|
}
|
|
>
|
|
{row.zone_name}
|
|
</Button>
|
|
),
|
|
},
|
|
{
|
|
key: 'group',
|
|
header: 'Группа',
|
|
sortable: true,
|
|
sortValue: (row) => row.group_name ?? 'Без группы',
|
|
cell: (row) => {
|
|
if (row.group_id && row.group_name) {
|
|
return (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0"
|
|
nativeButton={false}
|
|
render={
|
|
<Link
|
|
to="/groups/$groupId"
|
|
params={{ groupId: String(row.group_id) }}
|
|
/>
|
|
}
|
|
>
|
|
{row.group_name}
|
|
</Button>
|
|
)
|
|
}
|
|
return <Badge variant="outline">Без группы</Badge>
|
|
},
|
|
},
|
|
{
|
|
key: 'service_count',
|
|
header: 'Сервисы',
|
|
sortable: true,
|
|
sortValue: (row) => row.service_count,
|
|
className: 'tabular-nums',
|
|
cell: (row) => (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 tabular-nums"
|
|
nativeButton={false}
|
|
render={<Link to="/services" search={{ domainId: row.id }} />}
|
|
>
|
|
{row.service_count}
|
|
</Button>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Статус',
|
|
cell: (row) => <StatusBadge status={row.status} />,
|
|
},
|
|
{
|
|
key: 'last_synced_at',
|
|
header: 'Синхронизация',
|
|
sortable: true,
|
|
sortValue: (row) => row.last_synced_at ?? '',
|
|
className: 'text-muted-foreground tabular-nums',
|
|
cell: (row) => row.last_synced_at ?? '—',
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
enableHiding: false,
|
|
className: 'text-right',
|
|
cell: (row) => (
|
|
<div className="text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<Button variant="outline" size="icon" className="size-8" />}
|
|
>
|
|
<MoreHorizontalIcon />
|
|
<span className="sr-only">Действия</span>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
render={
|
|
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
|
}
|
|
>
|
|
Обзор
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
render={
|
|
<Link
|
|
to="/domains/$domainId/dns"
|
|
params={{ domainId: String(row.id) }}
|
|
search={{ host: undefined }}
|
|
/>
|
|
}
|
|
>
|
|
DNS
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={isDeleting}
|
|
onClick={() => setDeleteTarget(row)}
|
|
>
|
|
Удалить
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[isDeleting],
|
|
)
|
|
|
|
const groupSelect = (
|
|
<Select
|
|
items={[{ label: 'Все группы', value: 'all' }, ...groupFilterItems]}
|
|
value={groupFilterValue || 'all'}
|
|
onValueChange={(value) => {
|
|
if (!value || value === 'all') {
|
|
onGroupFilterChange(null)
|
|
return
|
|
}
|
|
onGroupFilterChange(value)
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-56">
|
|
<SelectValue placeholder="Все группы" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Все группы</SelectItem>
|
|
{groupFilterItems.map((item) => (
|
|
<SelectItem key={item.value} value={item.value}>
|
|
{item.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
|
|
const hasActiveFilters = Boolean(groupFilterValue) || Boolean(search.trim())
|
|
|
|
return (
|
|
<>
|
|
<DataGridCard
|
|
title="Список доменов"
|
|
description="Импортированные зоны Cloudflare"
|
|
columns={columnDefFromDataGrid(columns)}
|
|
data={filteredData}
|
|
rowId={(row) => String(row.id)}
|
|
emptyTitle="Домены не найдены"
|
|
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
|
|
emptyAction={emptyAction}
|
|
pinLastColumn
|
|
actions={
|
|
<DomainsFiltersToolbar
|
|
search={search}
|
|
onSearchChange={onSearchChange}
|
|
groupSelect={groupSelect}
|
|
shownCount={filteredData.length}
|
|
totalCount={totalCount}
|
|
hasActiveFilters={hasActiveFilters}
|
|
onReset={() => {
|
|
onSearchChange('')
|
|
onGroupFilterChange(null)
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<ConfirmDialog
|
|
open={deleteTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteTarget(null)
|
|
}}
|
|
title="Удалить зону?"
|
|
description={
|
|
deleteTarget
|
|
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
|
|
: ''
|
|
}
|
|
onConfirm={() => {
|
|
if (!deleteTarget) return
|
|
onDelete(deleteTarget)
|
|
setDeleteTarget(null)
|
|
}}
|
|
disabled={isDeleting}
|
|
/>
|
|
</>
|
|
)
|
|
}
|