feat(ui): единый PageHeader h1 на всех разделах
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m4s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m45s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s

Заголовок раздела над KPI/контентом; FrameTitle text-sm больше не заменяет page-level h1.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 14:29:44 +07:00
co-authored by Cursor
parent 6c379f339b
commit 704624cbe7
8 changed files with 136 additions and 57 deletions
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
interface PageHeaderProps {
title: string
description?: ReactNode
actions?: ReactNode
className?: string
}
/**
* Page-level section header (h1) — shared by dashboard, lists, settings.
* Distinct from FrameTitle (text-sm inside Frame panels).
*/
export function PageHeader({
title,
description,
actions,
className,
}: PageHeaderProps) {
return (
<header
className={cn(
'flex flex-wrap items-start justify-between gap-3 px-1',
className,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h1>
{description ? (
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</header>
)
}
@@ -7,6 +7,7 @@ import {
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton' import { Skeleton } from '@cfdm/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCard export type OpsKpiCard = KpiStatCard
@@ -54,14 +55,7 @@ export function OpsDashboard({
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<header className="px-1"> <PageHeader title={title} description={description} />
<div className="flex flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</div>
</header>
<section aria-label="Ключевые метрики"> <section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} /> <KpiStatGrid cards={kpiCards} skeletonCount={4} />
@@ -5,6 +5,7 @@ import { SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile' import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@cfdm/ui/components/separator' import { Separator } from '@cfdm/ui/components/separator'
export interface SettingsTabConfig { export interface SettingsTabConfig {
@@ -40,15 +41,10 @@ export function SettingsShell({
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<header className="flex flex-col gap-3 px-1"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-px"> <PageHeader title={title} description={description} />
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</div>
<Separator /> <Separator />
</header> </div>
<div <div
className={cn( className={cn(
@@ -192,6 +192,7 @@ interface ServicesGroupedCatalogProps {
domainLabel?: string domainLabel?: string
isLoading?: boolean isLoading?: boolean
primaryAction?: ReactNode primaryAction?: ReactNode
hideHeader?: boolean
togglingId: number | null togglingId: number | null
activeTab?: string activeTab?: string
onTabChange?: (tabId: string) => void onTabChange?: (tabId: string) => void
@@ -210,6 +211,7 @@ export function ServicesGroupedCatalog({
domainLabel, domainLabel,
isLoading = false, isLoading = false,
primaryAction, primaryAction,
hideHeader = false,
togglingId, togglingId,
activeTab: controlledTab, activeTab: controlledTab,
onTabChange, onTabChange,
@@ -347,21 +349,23 @@ export function ServicesGroupedCatalog({
tableLayout={{ dense: true }} tableLayout={{ dense: true }}
> >
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3"> {!hideHeader ? (
<div className="flex min-w-0 flex-col gap-px"> <FrameHeader className="flex-row items-start justify-between gap-3">
<FrameTitle>Сервисы</FrameTitle> <div className="flex min-w-0 flex-col gap-px">
<FrameDescription> <FrameTitle>Сервисы</FrameTitle>
{domainLabel <FrameDescription>
? `Каталог сервисов с привязками к ${domainLabel}` {domainLabel
: 'Группы, FQDN и доступность сервисов'} ? `Каталог сервисов с привязками к ${domainLabel}`
</FrameDescription> : 'Группы, FQDN и доступность сервисов'}
</div> </FrameDescription>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div> </div>
) : null} {primaryAction ? (
</FrameHeader> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!"> <FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)"> <div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
+7 -1
View File
@@ -6,6 +6,7 @@ import type { Filter } from '@/components/reui/filters'
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries' import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
import { CertKpiCards } from '@/components/cert-kpi-cards' import { CertKpiCards } from '@/components/cert-kpi-cards'
import { import {
@@ -71,6 +72,11 @@ function CertificatesPage() {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Сертификаты"
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
actions={primaryAction}
/>
<CertKpiCards <CertKpiCards
summary={summary} summary={summary}
total={certs?.length} total={certs?.length}
@@ -81,6 +87,7 @@ function CertificatesPage() {
<ResourcePage <ResourcePage
title="Сертификаты" title="Сертификаты"
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»" description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
hideHeader
tabs={CERT_TABS.map((tab) => ({ ...tab }))} tabs={CERT_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
@@ -97,7 +104,6 @@ function CertificatesPage() {
isError={isError} isError={isError}
error={error} error={error}
onRetry={refetch} onRetry={refetch}
primaryAction={primaryAction}
emptyState={{ emptyState={{
title: 'Сертификаты не найдены', title: 'Сертификаты не найдены',
description: 'Сертификаты появятся после проверки доменов', description: 'Сертификаты появятся после проверки доменов',
+7 -1
View File
@@ -15,6 +15,7 @@ import {
serviceBindingKeys, serviceBindingKeys,
} from '@/queries' } from '@/queries'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { DomainKpiCards } from '@/components/domain-kpi-cards' import { DomainKpiCards } from '@/components/domain-kpi-cards'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
import { import {
@@ -149,6 +150,11 @@ function DomainsPage() {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Домены"
description="Импортированные зоны Cloudflare"
actions={primaryAction}
/>
<DomainKpiCards <DomainKpiCards
domains={domains} domains={domains}
activeTab={activeTab} activeTab={activeTab}
@@ -158,6 +164,7 @@ function DomainsPage() {
<ResourcePage <ResourcePage
title="Домены" title="Домены"
description="Импортированные зоны Cloudflare" description="Импортированные зоны Cloudflare"
hideHeader
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))} tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
@@ -174,7 +181,6 @@ function DomainsPage() {
isError={isError} isError={isError}
error={error} error={error}
onRetry={refetch} onRetry={refetch}
primaryAction={primaryAction}
enableRowSelection enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => ( selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar <DomainsBulkToolbar
+28 -12
View File
@@ -14,6 +14,7 @@ import {
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import type { CreateGroupInput, Group } from '@/lib/schemas' import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { GroupKpiCards } from '@/components/group-kpi-cards' import { GroupKpiCards } from '@/components/group-kpi-cards'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -202,15 +203,31 @@ function GroupsPage() {
</Button> </Button>
) )
const headerActions = (
<>
{createButton}
<CatalogBoardToggle
view={view}
onViewChange={setView}
/>
</>
)
if (isLoading) { if (isLoading) {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Группы"
description="Каталог групп доменов"
actions={headerActions}
/>
{view === 'board' ? ( {view === 'board' ? (
<KanbanBoardSkeleton columns={3} /> <KanbanBoardSkeleton columns={3} />
) : ( ) : (
<ResourcePage <ResourcePage
title="Группы" title="Группы"
description="Каталог групп доменов" description="Каталог групп доменов"
hideHeader
isLoading isLoading
filterFields={[]} filterFields={[]}
filters={[]} filters={[]}
@@ -228,6 +245,7 @@ function GroupsPage() {
if (isError) { if (isError) {
return ( return (
<PageShell> <PageShell>
<PageHeader title="Группы" description="Каталог групп доменов" />
<EmptyState <EmptyState
title="Не удалось загрузить группы" title="Не удалось загрузить группы"
description={error?.message} description={error?.message}
@@ -243,6 +261,15 @@ function GroupsPage() {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Группы"
description={
view === 'board'
? 'Перетащите домен в группу или создайте новую группу'
: 'Каталог групп доменов'
}
actions={headerActions}
/>
{view === 'board' ? ( {view === 'board' ? (
(groups?.length ?? 0) === 0 && (domains?.length ?? 0) === 0 ? ( (groups?.length ?? 0) === 0 && (domains?.length ?? 0) === 0 ? (
<EmptyState <EmptyState
@@ -263,12 +290,6 @@ function GroupsPage() {
columns={columnConfigs} columns={columnConfigs}
value={kanbanValue} value={kanbanValue}
onValueChange={handleKanbanValueChange} onValueChange={handleKanbanValueChange}
toolbarActions={
<>
{createButton}
<CatalogBoardToggle view="board" onViewChange={setView} />
</>
}
renderCard={(domain, ctx) => ( renderCard={(domain, ctx) => (
<DomainKanbanCard <DomainKanbanCard
domain={domain} domain={domain}
@@ -289,6 +310,7 @@ function GroupsPage() {
<ResourcePage <ResourcePage
title="Группы" title="Группы"
description="Каталог групп доменов" description="Каталог групп доменов"
hideHeader
tabs={GROUP_TABS.map((tab) => ({ ...tab }))} tabs={GROUP_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
@@ -301,12 +323,6 @@ function GroupsPage() {
columns={columns} columns={columns}
data={catalogRows} data={catalogRows}
getRowId={(row) => String(row.id)} getRowId={(row) => String(row.id)}
primaryAction={
<>
{createButton}
<CatalogBoardToggle view="catalog" onViewChange={setView} />
</>
}
emptyState={{ emptyState={{
title: 'Группы не найдены', title: 'Группы не найдены',
description: 'Создайте группу для организации доменов.', description: 'Создайте группу для организации доменов.',
+25 -13
View File
@@ -21,6 +21,7 @@ import type {
UpdateServiceConfigInput, UpdateServiceConfigInput,
} from '@/lib/schemas' } from '@/lib/schemas'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ServiceKpiCards } from '@/components/service-kpi-cards' import { ServiceKpiCards } from '@/components/service-kpi-cards'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -437,6 +438,17 @@ function ServicesPage() {
/> />
) )
const headerActions = (
<>
{addMenu}
<CatalogBoardToggle view={view} onViewChange={setView} />
</>
)
const pageDescription = filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Группы, FQDN и доступность сервисов'
const sheets = ( const sheets = (
<> <>
<ServiceEditSheet <ServiceEditSheet
@@ -528,12 +540,18 @@ function ServicesPage() {
if (isLoading) { if (isLoading) {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Сервисы"
description={pageDescription}
actions={headerActions}
/>
{view === 'board' ? ( {view === 'board' ? (
<KanbanBoardSkeleton columns={3} /> <KanbanBoardSkeleton columns={3} />
) : ( ) : (
<ServicesGroupedCatalog <ServicesGroupedCatalog
data={{ groups: [], ungrouped: [] }} data={{ groups: [], ungrouped: [] }}
isLoading isLoading
hideHeader
togglingId={null} togglingId={null}
onEditService={() => {}} onEditService={() => {}}
onDeleteService={() => {}} onDeleteService={() => {}}
@@ -551,6 +569,7 @@ function ServicesPage() {
if (isError) { if (isError) {
return ( return (
<PageShell> <PageShell>
<PageHeader title="Сервисы" description={pageDescription} />
<EmptyState <EmptyState
title="Не удалось загрузить сервисы" title="Не удалось загрузить сервисы"
description={error?.message} description={error?.message}
@@ -567,6 +586,11 @@ function ServicesPage() {
return ( return (
<PageShell> <PageShell>
<PageHeader
title="Сервисы"
description={pageDescription}
actions={headerActions}
/>
{view === 'board' ? ( {view === 'board' ? (
<> <>
<ServicesBulkToolbar <ServicesBulkToolbar
@@ -588,7 +612,6 @@ function ServicesPage() {
action={ action={
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{addMenu} {addMenu}
<CatalogBoardToggle view="board" onViewChange={setView} />
</div> </div>
} }
/> />
@@ -603,12 +626,6 @@ function ServicesPage() {
columns={columnConfigs} columns={columnConfigs}
value={kanbanValue} value={kanbanValue}
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange} onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
toolbarActions={
<>
{addMenu}
<CatalogBoardToggle view="board" onViewChange={setView} />
</>
}
renderCard={(service, ctx) => ( renderCard={(service, ctx) => (
<ServiceKanbanCard <ServiceKanbanCard
service={service} service={service}
@@ -638,18 +655,13 @@ function ServicesPage() {
togglingId={togglingServiceId} togglingId={togglingServiceId}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
hideHeader
onEditService={setEditingService} onEditService={setEditingService}
onDeleteService={setDeletingService} onDeleteService={setDeletingService}
onToggleService={handleServiceToggle} onToggleService={handleServiceToggle}
onEditGroup={setEditingGroup} onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup} onDeleteGroup={setDeletingGroup}
onAddServiceToGroup={handleOpenCreateService} onAddServiceToGroup={handleOpenCreateService}
primaryAction={
<>
{addMenu}
<CatalogBoardToggle view="catalog" onViewChange={setView} />
</>
}
emptyAction={ emptyAction={
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{addMenu} {addMenu}