refactor(ui): выровнять lists и domain detail под ResourcePage

Bulk toolbar на Frame/muted; availability на Field+Item; domain detail на CountedLineTabs; group detail с DetailPanel.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 13:52:20 +07:00
co-authored by Cursor
parent 57ee231417
commit 52edc3ba73
4 changed files with 270 additions and 242 deletions
@@ -18,20 +18,6 @@ import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
AppItemSeparator,
AppItemTitle,
} from '@/components/app-item'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -40,6 +26,16 @@ import {
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -178,21 +174,19 @@ export function DomainAvailabilityPanel({
createMutation.mutate() createMutation.mutate()
}} }}
> >
<AppFieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end"> <FieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<AppField> <Field>
<AppFieldLabel htmlFor="monitor-hostname"> <FieldLabel htmlFor="monitor-hostname">Hostname</FieldLabel>
Hostname <Input
</AppFieldLabel>
<AppInput
id="monitor-hostname" id="monitor-hostname"
value={hostname} value={hostname}
placeholder="example.com" placeholder="example.com"
onChange={(e) => setHostname(e.target.value)} onChange={(e) => setHostname(e.target.value)}
autoComplete="off" autoComplete="off"
/> />
</AppField> </Field>
<AppField> <Field>
<AppFieldLabel htmlFor="monitor-type">Тип</AppFieldLabel> <FieldLabel htmlFor="monitor-type">Тип</FieldLabel>
<Select <Select
items={MONITOR_TYPE_ITEMS} items={MONITOR_TYPE_ITEMS}
value={monitorType} value={monitorType}
@@ -213,7 +207,7 @@ export function DomainAvailabilityPanel({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</AppField> </Field>
<LoadingButton <LoadingButton
type="submit" type="submit"
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
@@ -223,7 +217,7 @@ export function DomainAvailabilityPanel({
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
Добавить Добавить
</LoadingButton> </LoadingButton>
</AppFieldGroup> </FieldGroup>
</form> </form>
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -251,14 +245,14 @@ export function DomainAvailabilityPanel({
description="Добавьте hostname для проверки доступности" description="Добавьте hostname для проверки доступности"
/> />
) : ( ) : (
<AppItemGroup className="gap-0"> <ItemGroup className="gap-0">
{monitors.map((monitor, index) => ( {monitors.map((monitor, index) => (
<div key={monitor.id}> <div key={monitor.id}>
<AppItem size="sm" variant="muted" className="border-0 px-0"> <Item size="sm" variant="muted" className="border-0 px-0">
<AppItemContent className="gap-1"> <ItemContent className="gap-1">
<AppItemTitle className="font-mono"> <ItemTitle className="font-mono">
{monitor.hostname} {monitor.hostname}
</AppItemTitle> </ItemTitle>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"> <div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<span className="uppercase"> <span className="uppercase">
{monitorTypeLabel(monitor.type)} {monitorTypeLabel(monitor.type)}
@@ -271,8 +265,8 @@ export function DomainAvailabilityPanel({
<span>Ещё не проверялся</span> <span>Ещё не проверялся</span>
)} )}
</div> </div>
</AppItemContent> </ItemContent>
<AppItemActions className="gap-2"> <ItemActions className="gap-2">
<HealthCheckBadge <HealthCheckBadge
status={monitor.last_status} status={monitor.last_status}
latencyMs={monitor.last_latency_ms} latencyMs={monitor.last_latency_ms}
@@ -299,14 +293,12 @@ export function DomainAvailabilityPanel({
</Button> </Button>
} }
/> />
</AppItemActions> </ItemActions>
</AppItem> </Item>
{index < monitors.length - 1 ? ( {index < monitors.length - 1 ? <ItemSeparator /> : null}
<AppItemSeparator />
) : null}
</div> </div>
))} ))}
</AppItemGroup> </ItemGroup>
)} )}
</QueryState> </QueryState>
</FramePanel> </FramePanel>
@@ -1,4 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
Select, Select,
@@ -42,89 +44,97 @@ export function DomainsBulkToolbar({
] ]
return ( return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2"> <Frame dense spacing="sm" className="mb-3 w-full">
<span className="text-muted-foreground text-sm tabular-nums"> <FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
Выбрано: {count} <Badge size="sm" variant="secondary" className="tabular-nums">
</span> {count} выбрано
<Select </Badge>
items={groupItems} <Select
value={groupValue} items={groupItems}
onValueChange={(v) => setGroupValue(v ?? 'none')} value={groupValue}
> onValueChange={(v) => setGroupValue(v ?? 'none')}
<SelectTrigger className="w-44" size="sm"> >
<SelectValue placeholder="Группа" /> <SelectTrigger className="w-44" size="sm">
</SelectTrigger> <SelectValue placeholder="Группа" />
<SelectContent> </SelectTrigger>
{groupItems.map((item) => ( <SelectContent>
<SelectItem key={item.value} value={item.value}> {groupItems.map((item) => (
{item.label} <SelectItem key={item.value} value={item.value}>
</SelectItem> {item.label}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Button </Select>
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
disabled={isPending} size="sm"
onClick={() => disabled={isPending}
onAssignGroup(groupValue === 'none' ? null : Number(groupValue)) onClick={() =>
} onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
> }
В группу >
</Button> В группу
<Select </Button>
items={envItems} <Select
value={envValue} items={envItems}
onValueChange={(v) => setEnvValue(v ?? 'none')} value={envValue}
> onValueChange={(v) => setEnvValue(v ?? 'none')}
<SelectTrigger className="w-32" size="sm"> >
<SelectValue placeholder="Env" /> <SelectTrigger className="w-32" size="sm">
</SelectTrigger> <SelectValue placeholder="Env" />
<SelectContent> </SelectTrigger>
{envItems.map((item) => ( <SelectContent>
<SelectItem key={item.value} value={item.value}> {envItems.map((item) => (
{item.label} <SelectItem key={item.value} value={item.value}>
</SelectItem> {item.label}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Button </Select>
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
disabled={isPending} size="sm"
onClick={() => disabled={isPending}
onSetEnvironment( onClick={() =>
envValue === 'none' onSetEnvironment(
? null envValue === 'none'
: (envValue as 'prod' | 'staging' | 'dev'), ? null
) : (envValue as 'prod' | 'staging' | 'dev'),
} )
> }
Env >
</Button> Env
<Input </Button>
className="h-8 w-32" <Input
placeholder="тег" className="h-8 w-32"
value={tag} placeholder="тег"
onChange={(e) => setTag(e.target.value)} value={tag}
/> onChange={(e) => setTag(e.target.value)}
<Button />
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
disabled={isPending || !tag.trim()} size="sm"
onClick={() => { disabled={isPending || !tag.trim()}
onAddTag(tag.trim()) onClick={() => {
setTag('') onAddTag(tag.trim())
}} setTag('')
> }}
+ тег >
</Button> + тег
<Button type="button" variant="ghost" size="sm" disabled={isPending} onClick={onClear}> </Button>
Снять выделение <Button
</Button> type="button"
</div> variant="ghost"
size="sm"
disabled={isPending}
onClick={onClear}
>
Снять выделение
</Button>
</FramePanel>
</Frame>
) )
} }
@@ -24,6 +24,7 @@ import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page' import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { DetailPanel, ResourcePage } from '@/components/reui-kit' import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { import {
SUBDOMAIN_TABS, SUBDOMAIN_TABS,
@@ -53,12 +54,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import { import { TabsContent } from '@cfdm/ui/components/tabs'
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({ export const Route = createFileRoute('/_auth/domains/$domainId/')({
loader: async ({ context: { queryClient }, params }) => { loader: async ({ context: { queryClient }, params }) => {
@@ -327,50 +323,26 @@ function DomainOverviewPage() {
actions={headerActions} actions={headerActions}
/> />
<Tabs <CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'dns', label: 'DNS' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
label: 'Поддомены',
count: subdomainRows.length,
},
{
id: 'bindings',
label: 'Привязки',
count: bindings.length,
},
]}
value={activeTab} value={activeTab}
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="flex w-full flex-col gap-4" className="flex w-full flex-col gap-4"
> >
<TabsList variant="line" className="gap-5">
<TabsTrigger
value="overview"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Обзор</span>
</TabsTrigger>
<TabsTrigger
value="dns"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>DNS</span>
</TabsTrigger>
<TabsTrigger
value="availability"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Доступность</span>
</TabsTrigger>
<TabsTrigger
value="subdomains"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Поддомены</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{subdomainRows.length}
</span>
</TabsTrigger>
<TabsTrigger
value="bindings"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Привязки</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{bindings.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4"> <TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} /> <DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section <DetailPanel.Section
@@ -438,50 +410,43 @@ function DomainOverviewPage() {
</TabsContent> </TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4"> <TabsContent value="subdomains" className="flex flex-col gap-4">
<DetailPanel.Section title="Поддомены"> <ResourcePage
<ResourcePage title="Поддомены"
title="Поддомены" description={`Записи в зоне ${domain.zone_name}`}
description={`Записи в зоне ${domain.zone_name}`} hideHeader
hideHeader tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))} tabFilter={subdomainTabFilter}
tabFilter={subdomainTabFilter} filterFields={filterFields}
filterFields={filterFields} filters={filters}
filters={filters} onFiltersChange={setFilters}
onFiltersChange={setFilters} onClearFilters={() =>
onClearFilters={() => setFilters(createDefaultSubdomainFilters())
setFilters(createDefaultSubdomainFilters()) }
} getFilterFieldValue={subdomainFilterFieldValue}
getFilterFieldValue={subdomainFilterFieldValue} columns={columns}
columns={columns} data={subdomainRows}
data={subdomainRows} getRowId={(row) => String(row.subdomain.id)}
getRowId={(row) => String(row.subdomain.id)} toolbarExtra={
toolbarExtra={ <Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}> <Button type="button" onClick={openCreateSheet}>
Добавить поддомен Добавить поддомен
</Button> </Button>
} ),
emptyState={{ }}
title: 'Поддомены не созданы', />
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</TabsContent> </TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4"> <TabsContent value="bindings" className="flex flex-col gap-4">
<DetailPanel.Section <DomainBindingsCard bindings={bindings} />
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
</TabsContent> </TabsContent>
</Tabs> </CountedLineTabs>
<SubdomainEditSheet <SubdomainEditSheet
mode={sheetMode} mode={sheetMode}
+98 -37
View File
@@ -1,13 +1,14 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { FolderTreeIcon, GlobeIcon, HashIcon } from 'lucide-react'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { import {
domainsListQueryOptions, domainsListQueryOptions,
groupDetailQueryOptions, groupDetailQueryOptions,
} from '@/queries' } from '@/queries'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { ResourcePage } from '@/components/reui-kit' import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { import {
createDefaultGroupDomainFilters, createDefaultGroupDomainFilters,
groupDomainFilterFieldValue, groupDomainFilterFieldValue,
@@ -52,48 +53,108 @@ function GroupDetailPage() {
const filterFields = useGroupDomainFilterFields() const filterFields = useGroupDomainFilterFields()
const columns = useGroupDomainColumns() const columns = useGroupDomainColumns()
const primaryAction = ( const headerActions = (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}> <Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
К группам К группам
</Button> </Button>
) )
const metricCards = useMemo(() => {
if (!group) return []
return [
{
id: 'domains',
icon: <GlobeIcon aria-hidden="true" />,
label: 'Домены',
description: 'Зоны в этой группе',
footer: (
<span className="text-foreground text-lg font-semibold tabular-nums">
{group.domain_count}
</span>
),
},
{
id: 'slug',
icon: <HashIcon aria-hidden="true" />,
label: 'Slug',
description: 'Идентификатор группы',
footer: (
<span className="text-foreground font-mono text-sm">{group.slug}</span>
),
},
{
id: 'group',
icon: <FolderTreeIcon aria-hidden="true" />,
label: 'Группа',
description: group.name,
footer: (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={<Link to="/groups" search={{ view: 'board' }} />}
>
Открыть канбан
</Button>
),
},
]
}, [group])
return ( return (
<PageShell> <PageShell>
<ResourcePage <DetailPanel>
title={group?.name ?? 'Группа'} <DetailPanel.Header
description={ title={group?.name ?? 'Группа'}
group description={
? `${group.domain_count} домен(ов) · slug: ${group.slug}` group
: 'Домены в группе' ? `${group.domain_count} домен(ов) · slug: ${group.slug}`
} : 'Домены в группе'
filterFields={filterFields} }
filters={filters} actions={headerActions}
onFiltersChange={setFilters} />
onClearFilters={() => setFilters(createDefaultGroupDomainFilters())}
getFilterFieldValue={groupDomainFilterFieldValue} {!isLoading && group ? (
columns={columns} <DetailPanel.Metrics cards={metricCards} />
data={domains ?? []} ) : null}
getRowId={(row) => String(row.id)}
isLoading={isLoading} <DetailPanel.Section>
isError={isError} <ResourcePage
error={error} title="Домены группы"
onRetry={() => { description="Зоны, назначенные в эту группу"
void refetchGroup() hideHeader
void refetchDomains() filterFields={filterFields}
}} filters={filters}
primaryAction={primaryAction} onFiltersChange={setFilters}
emptyState={{ onClearFilters={() => setFilters(createDefaultGroupDomainFilters())}
title: 'В группе нет доменов', getFilterFieldValue={groupDomainFilterFieldValue}
description: columns={columns}
'Перетащите домены на канбане групп или назначьте группу при импорте', data={domains ?? []}
action: ( getRowId={(row) => String(row.id)}
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}> isLoading={isLoading}
Открыть канбан групп isError={isError}
</Button> error={error}
), onRetry={() => {
}} void refetchGroup()
/> void refetchDomains()
}}
emptyState={{
title: 'В группе нет доменов',
description:
'Перетащите домены на канбане групп или назначьте группу при импорте',
action: (
<Button
variant="outline"
nativeButton={false}
render={<Link to="/groups" />}
>
Открыть канбан групп
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
</PageShell> </PageShell>
) )
} }