Выравнивание UI с vps-tracker: тема b2fA, ReUI DataGrid и shell
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
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]>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user