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