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
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:
@@ -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)">
|
||||
|
||||
@@ -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: 'Сертификаты появятся после проверки доменов',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 'Создайте группу для организации доменов.',
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user