feat(web): собрать экраны из блоков ReUI PRO
CD / update-wiki (push) Successful in 6s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 52s
CD / quality (push) Failing after 1m5s
CD / publish (push) Skipped

Подключить stats-12, card-12, settings-3 и empty-state-12 к оболочке, спискам, карточкам и настройкам, чтобы метрики и пустые состояния шли из одних блоков.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 19:40:18 +07:00
1 parent 73f5380768
commit 2418344551
58 files changed
+1810 -653

No files matched your search

@@ -0,0 +1,16 @@
import { Frame } from "@/components/reui/frame"
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<Frame className="w-full">
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,19 @@
import { FramePanel } from "@/components/reui/frame"
import { ICard } from "./data"
export function CardItem({ card }: { card: ICard }) {
return (
<a
href={card.href}
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
>
<FramePanel className="space-y-7.5 p-3.5">
{card.icon}
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
{card.title}
</h3>
</FramePanel>
</a>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from "react"
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
export interface ICard {
title: string
href: string
icon: ReactNode
}
export const CARDS: ICard[] = [
{
title: "Valuation",
href: "#valuation",
icon: (
<ActivityIcon aria-hidden="true" />
),
},
{
title: "Ledger",
href: "#ledger",
icon: (
<BookOpenIcon aria-hidden="true" />
),
},
{
title: "Funding",
href: "#funding",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
title: "Cash Flow",
href: "#cash-flow",
icon: (
<CircleDollarSignIcon aria-hidden="true" />
),
},
{
title: "Metrics",
href: "#metrics",
icon: (
<BarChart3Icon aria-hidden="true" />
),
},
{
title: "Earnings",
href: "#earnings",
icon: (
<TrendingUp aria-hidden="true" />
),
},
]
@@ -0,0 +1,9 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
<CardGrid />
</div>
)
}
@@ -0,0 +1,48 @@
export type ToolbarOption<T extends string> = {
value: T
label: string
}
export const EXPORT_AUDIENCE_OPTIONS = [
{ value: "everyone", label: "Everyone" },
{ value: "ops-leads", label: "Ops leads" },
{ value: "finance-reviewers", label: "Finance reviewers" },
{ value: "client-owners", label: "Client owners" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_SCOPE_OPTIONS = [
{ value: "all-workspaces", label: "All workspaces" },
{ value: "harbor-field", label: "Harbor field" },
{ value: "market-lab", label: "Market lab" },
{ value: "support-desk", label: "Support desk" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_RANGE_OPTIONS = [
{ value: "last-30-days", label: "Last 30 days" },
{ value: "this-quarter", label: "This quarter" },
{ value: "previous-cycle", label: "Previous cycle" },
{ value: "custom-window", label: "Custom window" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_FORMAT_OPTIONS = [
{
value: "csv",
label: "CSV bundle",
description: "Spreadsheet-ready activity rows",
},
{
value: "pdf",
label: "PDF brief",
description: "A concise review packet for stakeholders",
},
{
value: "schedule",
label: "Schedule delivery",
description: "Send this export every Friday morning",
},
] as const
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
@@ -0,0 +1,249 @@
"use client"
import { useState } from "react"
import { IconStack } from "@/components/reui/icon-stack"
import { toast } from "sonner"
import { Button } from "@cfdm/ui/components/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@cfdm/ui/components/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@cfdm/ui/components/dropdown-menu"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@cfdm/ui/components/empty"
import { Separator } from "@cfdm/ui/components/separator"
import {
EXPORT_AUDIENCE_OPTIONS,
EXPORT_FORMAT_OPTIONS,
EXPORT_RANGE_OPTIONS,
EXPORT_SCOPE_OPTIONS,
type ExportAudience,
type ExportFormat,
type ExportRange,
type ExportScope,
type ToolbarOption,
} from "./data"
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
type ToolbarFilterProps<T extends string> = {
label: string
value: T
options: readonly ToolbarOption<T>[]
onValueChange: (value: T) => void
}
function getOptionLabel<T extends string>(
options: readonly ToolbarOption<T>[],
value: T
) {
return options.find((option) => option.value === value)?.label ?? value
}
function ToolbarFilter<T extends string>({
label,
value,
options,
onValueChange,
}: ToolbarFilterProps<T>) {
const selectedLabel = getOptionLabel(options, value)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-label={`${label}: ${selectedLabel}`}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
if (nextValue !== null) {
onValueChange(nextValue as T)
}
}}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function EmptyState() {
const [audience, setAudience] = useState<ExportAudience>(
EXPORT_AUDIENCE_OPTIONS[0].value
)
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
const showExportToast = (format: ExportFormat = "csv") => {
const formatOption =
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
EXPORT_FORMAT_OPTIONS[0]
toast.message(`${formatOption.label} is ready to wire`, {
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
})
}
return (
<section
className="flex min-h-[430px] w-full max-w-4xl flex-col"
aria-labelledby="export-ledger-heading"
>
{/* Header */}
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
{/* Heading */}
<div className="flex min-w-0 flex-col gap-5">
{/* Title and Description */}
<div className="flex flex-col gap-0.5">
{/* Title */}
<h2
id="export-ledger-heading"
className="text-2xl font-semibold tracking-tight"
>
Activity Exports
</h2>
{/* Description */}
<p className="text-muted-foreground text-sm">
Download scoped activity packets for billing review, staffing
audits, and client handoffs.
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<ToolbarFilter
label="Audience"
value={audience}
options={EXPORT_AUDIENCE_OPTIONS}
onValueChange={setAudience}
/>
<ToolbarFilter
label="Workspace"
value={scope}
options={EXPORT_SCOPE_OPTIONS}
onValueChange={setScope}
/>
<ToolbarFilter
label="Date range"
value={range}
options={EXPORT_RANGE_OPTIONS}
onValueChange={setRange}
/>
</div>
</div>
{/* Download Action */}
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
<Button
type="button"
className="flex-1 sm:flex-none"
onClick={() => showExportToast()}
>
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
<span>Execute</span>
</Button>
<ButtonGroupSeparator className="bg-primary/72" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon"
className="border-primary-foreground/20 rounded-l-none border-l"
aria-label="Open download options"
/>
}
>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => showExportToast("csv")}>
<FileDownIcon aria-hidden="true" />
CSV bundle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
<BookOpenIcon aria-hidden="true" />
PDF brief
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
<CalendarClockIcon aria-hidden="true" />
Schedule delivery
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
<Separator />
{/* Empty State */}
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
<Empty className="max-w-md flex-none bg-transparent p-0">
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true">
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
</IconStack>
</EmptyMedia>
{/* Empty State Content */}
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
No exportable activity yet
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Capture approved activity and this view will assemble your next
review packet.
</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</div>
</section>
)
}
@@ -0,0 +1,15 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Activity exports empty state
</h1>
<EmptyState />
</main>
)
}
@@ -0,0 +1,40 @@
import { useState } from "react"
import { cn } from "@cfdm/ui/lib/utils"
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
import { ACCENT_COLORS } from "./data"
import { CheckIcon } from "lucide-react"
// ── Color Picker ──
export function ColorPicker() {
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
return (
<FieldSet className="gap-0">
<FieldLegend className="sr-only">Accent color</FieldLegend>
{/* Actions */}
<div className="flex items-center gap-3">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
type="button"
aria-label={color.name}
onClick={() => setSelected(color.value)}
className={cn(
"flex size-7 items-center justify-center rounded-full transition-shadow",
selected === color.value &&
"ring-ring ring-offset-background ring-2 ring-offset-2"
)}
style={{ backgroundColor: color.value }}
>
{selected === color.value ? (
<CheckIcon className="size-3 text-white" aria-hidden="true" />
) : null}
</button>
))}
</div>
</FieldSet>
)
}
@@ -0,0 +1,109 @@
"use client"
import { type ReactNode } from "react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type Preference = {
id: string
label: string
description: string
defaultChecked: boolean
}
export type AccentColorOption = {
name: string
value: string
}
export type SettingBadgeVariant =
| "primary-light"
| "destructive-light"
| "info-light"
export type SettingFieldProps = {
title: string
description: string
badge?: { label: string; variant: SettingBadgeVariant }
children: ReactNode
last?: boolean
}
export type DateFormatOption = {
value: string
label: string
}
// ── Data ──
export const DAYS: SelectOption[] = [
{ value: "monday", label: "Monday" },
{ value: "tuesday", label: "Tuesday" },
{ value: "wednesday", label: "Wednesday" },
{ value: "thursday", label: "Thursday" },
{ value: "friday", label: "Friday" },
{ value: "saturday", label: "Saturday" },
{ value: "sunday", label: "Sunday" },
]
export const CURRENCIES: SelectOption[] = [
{ value: "usd", label: "USD · US Dollar" },
{ value: "eur", label: "EUR · Euro" },
{ value: "gbp", label: "GBP · British Pound" },
{ value: "jpy", label: "JPY · Japanese Yen" },
]
export const REGIONS: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "eu", label: "European Union" },
{ value: "uk", label: "United Kingdom" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
]
export const ACCENT_COLORS: AccentColorOption[] = [
{ name: "Slate", value: "#64748b" },
{ name: "Rose", value: "#f43f5e" },
{ name: "Orange", value: "#f97316" },
{ name: "Violet", value: "#8b5cf6" },
{ name: "Emerald", value: "#10b981" },
{ name: "Sky", value: "#0ea5e9" },
]
export const PREFERENCES: Preference[] = [
{
id: "keyboard-shortcuts",
label: "Enable keyboard shortcuts.",
description: "Use shortcuts to speed up your workflow.",
defaultChecked: true,
},
{
id: "spellcheck",
label: "Auto spell-check.",
description: "Highlight spelling errors in text fields.",
defaultChecked: true,
},
{
id: "compact-mode",
label: "Compact Mode.",
description: "Reduce spacing for a denser interface layout.",
defaultChecked: false,
},
{
id: "animations",
label: "Reduce animations.",
description: "Minimize motion effects throughout the UI.",
defaultChecked: false,
},
]
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
{ value: "mdy", label: "MM/DD/YYYY" },
{ value: "dmy", label: "DD/MM/YYYY" },
{ value: "ymd", label: "YYYY/MM/DD" },
]
@@ -0,0 +1,57 @@
import { useState } from "react"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@cfdm/ui/components/field"
import { Switch } from "@cfdm/ui/components/switch"
import { PREFERENCES } from "./data"
// ── Editor Preferences ──
export function EditorPreferences() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
)
return (
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">
Configure editing behavior and interface density.
</FieldDescription>
{/* List */}
<FieldGroup className="gap-3">
{PREFERENCES.map((pref) => (
<Field key={pref.id} orientation="horizontal" className="gap-3">
<Switch
id={`settings-3-${pref.id}`}
checked={values[pref.id]}
onCheckedChange={(value) =>
setValues((prev) => ({ ...prev, [pref.id]: value }))
}
className="mt-0.5"
/>
<FieldContent className="gap-0.5">
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
{pref.label}
</FieldLabel>
<FieldDescription className="text-xs">
{pref.description}
</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</FieldSet>
)
}
@@ -0,0 +1,267 @@
"use client"
import { useState } from "react"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@cfdm/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@cfdm/ui/components/field"
import { Input } from "@cfdm/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@cfdm/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@cfdm/ui/components/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@cfdm/ui/components/toggle-group"
import { ColorPicker } from "./color-picker"
import { CURRENCIES, DAYS, REGIONS } from "./data"
import { EditorPreferences } from "./editor-preferences"
import { SettingField } from "./setting-field"
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
export function GeneralSettings() {
const [dateFormat, setDateFormat] = useState(["mdy"])
const [startOfWeek, setStartOfWeek] = useState("monday")
const [region, setRegion] = useState("us")
const [currency, setCurrency] = useState("usd")
const handleStartOfWeekChange = (value: string | null) => {
if (value !== null) {
setStartOfWeek(value)
}
}
const handleRegionChange = (value: string | null) => {
if (value !== null) {
setRegion(value)
}
}
const handleCurrencyChange = (value: string | null) => {
if (value !== null) {
setCurrency(value)
}
}
return (
<Frame className="w-full max-w-3xl">
<FrameHeader className="px-2! py-2.5!">
<FrameTitle>General Settings</FrameTitle>
<FrameDescription>Core app preferences.</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
{/* ── Project name ── */}
<SettingField
title="Project name"
description="The display name for your project across the platform."
labelFor="settings-3-project-name"
>
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
</SettingField>
{/* ── API endpoint ── */}
<SettingField
title="API endpoint"
description="Set the base URL for your project API."
badge={{ label: "Required", variant: "destructive-light" }}
labelFor="settings-3-api-endpoint"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-3-api-endpoint"
defaultValue="api.acme.io"
/>
</InputGroup>
</SettingField>
{/* ── Start of week ── */}
<SettingField
title="Start of week"
description="Choose which day marks the start of your week."
labelFor="settings-3-start-of-week"
>
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
<SelectTrigger id="settings-3-start-of-week" className="w-full">
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DAYS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingField>
{/* ── Allowed origins ── */}
<SettingField
title="Allowed origins"
description="Define the trusted domains for CORS requests."
>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline">
<PlusIcon aria-hidden="true" />
Add origin
</Button>
<Button variant="outline">
<UploadIcon aria-hidden="true" />
Import from file
</Button>
</div>
</SettingField>
{/* ── Accent color ── */}
<SettingField
title="Accent color"
description="Select a color to represent your brand."
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
>
<ColorPicker />
</SettingField>
{/* ── Region & currency ── */}
<SettingField
title="Region & currency"
description="Adjust your regional preferences and currency."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Region and currency</FieldLegend>
<FieldDescription className="sr-only">
Regional and financial formatting preferences.
</FieldDescription>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger id="settings-3-region" className="w-full">
<GlobeIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(REGIONS, region)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-3-currency">
Currency
</FieldLabel>
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger id="settings-3-currency" className="w-full">
<CircleDollarSignIcon aria-hidden="true" />
<SelectValue>
{getOptionLabel(CURRENCIES, currency)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{CURRENCIES.map((currency) => (
<SelectItem
key={currency.value}
value={currency.value}
>
{currency.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingField>
{/* ── Date display format ── */}
<SettingField
title="Date display format"
description="Choose your preferred format for dates."
>
<ToggleGroup
multiple={false}
value={dateFormat}
onValueChange={(value) => {
if (value.length > 0) setDateFormat(value)
}}
variant="outline"
size="sm"
aria-label="Date display format"
>
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
</ToggleGroup>
</SettingField>
{/* ── Editor preferences ── */}
<SettingField
title="Editor preferences"
description="Adjust your editing environment and display options."
last
contentClassName="@md/field-group:w-[22rem]"
>
<EditorPreferences />
</SettingField>
</FieldGroup>
</FramePanel>
<FrameFooter className="flex flex-row justify-end gap-3">
<Button variant="outline">Reset</Button>
<Button>Save changes</Button>
</FrameFooter>
</Frame>
)
}
// ── Helper functions ──
function getOptionLabel(
options: Array<{ value: string; label: string }>,
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -0,0 +1,69 @@
import { type ComponentProps, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { cn } from "@cfdm/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@cfdm/ui/components/field"
interface SettingFieldProps {
title: string
description: string
badge?: {
label: string
variant: ComponentProps<typeof Badge>["variant"]
}
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
}
// ── Setting Field ──
export function SettingField({
title,
description,
badge,
children,
last,
labelFor,
contentClassName,
}: SettingFieldProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-4 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{badge ? (
<Badge variant={badge.variant} size="sm">
{badge.label}
</Badge>
) : null}
</div>
<FieldDescription className="text-xs">{description}</FieldDescription>
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,9 @@
import { GeneralSettings } from "./components/general-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
<GeneralSettings />
</div>
)
}
@@ -0,0 +1,47 @@
"use client"
import { ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
// ── Types ──
export interface CardData {
icon: ReactNode
iconBg: string
value: string | number
label: string
info: ReactNode
}
// ── Data ──
export const cards: CardData[] = [
{
icon: (
<Headphones aria-hidden="true" />
),
iconBg: "text-blue-600 dark:text-blue-400",
value: 320,
label: "Support Tickets",
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
},
{
icon: (
<CircleCheckIcon aria-hidden="true" />
),
iconBg: "text-emerald-600 dark:text-emerald-400",
value: "98%",
label: "Resolved",
info: <Badge variant="success-light">+2.1% this month</Badge>,
},
{
icon: (
<SmileIcon aria-hidden="true" />
),
iconBg: "text-amber-600 dark:text-amber-400",
value: "4.8",
label: "Satisfaction Rate",
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
},
]
@@ -0,0 +1,43 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { cn } from "@cfdm/ui/lib/utils"
import { Item, ItemMedia } from "@cfdm/ui/components/item"
import { cards } from "./data"
export function Stats() {
return (
<div className="@container w-full grow">
{/* Grid */}
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
{cards.map((card, i) => (
<Frame key={i}>
<FramePanel className="flex flex-col items-start gap-6">
<Item
className={cn(
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold">
{card.value}
</div>
<div className="text-muted-foreground text-sm font-medium">
{card.label}
</div>
</div>
{card.info}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { Stats } from "./components/stats"
export function Page() {
return (
<div className="flex w-full items-center justify-center p-10 md:p-20">
<Stats />
</div>
)
}
@@ -9,15 +9,11 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Input } from '@cfdm/ui/components/input'
interface DomainsBulkToolbarProps {
count: number
isPending?: boolean
groupItems: { label: string; value: string }[]
onAssignGroup: (groupId: number | null) => void
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
onAddTag: (tag: string) => void
onClear: () => void
}
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
isPending = false,
groupItems,
onAssignGroup,
onSetEnvironment,
onAddTag,
onClear,
}: DomainsBulkToolbarProps) {
const [groupValue, setGroupValue] = useState('none')
const [envValue, setEnvValue] = useState('none')
const [tag, setTag] = useState('')
if (count === 0) return null
const envItems = [
{ label: 'Без env', value: 'none' },
{ label: 'prod', value: 'prod' },
{ label: 'staging', value: 'staging' },
{ label: 'dev', value: 'dev' },
]
return (
<Frame dense spacing="sm" className="mb-3 w-full">
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
}
>
В группу
Назначить группу
</Button>
<Select
items={envItems}
value={envValue}
onValueChange={(v) => setEnvValue(v ?? 'none')}
>
<SelectTrigger className="w-32" size="sm">
<SelectValue placeholder="Env" />
</SelectTrigger>
<SelectContent>
{envItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
onSetEnvironment(
envValue === 'none'
? null
: (envValue as 'prod' | 'staging' | 'dev'),
)
}
onClick={() => onAssignGroup(null)}
>
Env
</Button>
<Input
className="h-8 w-32"
placeholder="тег"
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || !tag.trim()}
onClick={() => {
onAddTag(tag.trim())
setTag('')
}}
>
+ тег
Снять группу
</Button>
<Button
type="button"
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import {
BlocksIcon,
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
PaletteIcon,
ServerIcon,
@@ -21,6 +21,13 @@ import {
CommandList,
CommandSeparator,
} from '@cfdm/ui/components/command'
import {
domainsListQueryOptions,
groupsQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
const SECTION_ITEMS = [
{
@@ -61,35 +68,22 @@ const SECTION_ITEMS = [
},
] as const
const ACTION_ITEMS = [
{
to: '/settings/health',
label: 'Health-check',
keywords: 'health проверка пороги cron',
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Интеграции',
keywords: 'integrations app switcher портал',
icon: BlocksIcon,
},
{
to: '/settings/appearance',
label: 'Внешний вид',
keywords: 'appearance тема быстрые действия',
icon: PaletteIcon,
},
] as const
type PaletteTarget =
| (typeof SECTION_ITEMS)[number]['to']
| (typeof ACTION_ITEMS)[number]['to']
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
export function CommandPalette() {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
const { theme, setTheme } = useTheme()
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
const services = useMemo(() => {
const data = servicesQuery.data
if (!data) return []
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
}, [servicesQuery.data])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
@@ -99,8 +93,16 @@ export function CommandPalette() {
setOpen((current) => !current)
}
function onOpen() {
setOpen(true)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
}
}, [])
function go(to: PaletteTarget) {
@@ -117,9 +119,38 @@ export function CommandPalette() {
className="sm:max-w-md"
>
<Command key={open ? 'open' : 'closed'}>
<CommandInput autoFocus placeholder="Перейти к разделу или действию…" />
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Действия">
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
<GlobeIcon />
<span>Синхронизировать домены</span>
</CommandItem>
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
<ServerIcon />
<span>Создать сервис</span>
</CommandItem>
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
<FolderTreeIcon />
<span>Создать группу</span>
</CommandItem>
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
<ShieldCheckIcon />
<span>Проверить сертификаты</span>
</CommandItem>
<CommandItem
value="Переключить тему"
onSelect={() => {
setTheme(theme === 'light' ? 'dark' : 'light')
setOpen(false)
}}
>
<PaletteIcon />
<span>Переключить тему</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Разделы">
{SECTION_ITEMS.map((item) => (
<CommandItem
@@ -133,15 +164,57 @@ export function CommandPalette() {
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Действия">
{ACTION_ITEMS.map((item) => (
<CommandGroup heading="Домены">
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
<CommandItem
key={item.label}
value={`${item.label} ${item.keywords}`}
onSelect={() => go(item.to)}
key={domain.id}
value={`домен ${domain.zone_name}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/domains/$domainId',
params: { domainId: String(domain.id) },
})
}}
>
<item.icon />
<span>{item.label}</span>
<GlobeIcon />
<span>{domain.zone_name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Сервисы">
{services.slice(0, 8).map((service) => (
<CommandItem
key={service.id}
value={`сервис ${service.name}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/services/$serviceId',
params: { serviceId: String(service.id) },
})
}}
>
<ServerIcon />
<span>{service.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Группы">
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
<CommandItem
key={group.id}
value={`группа ${group.name} ${group.slug}`}
onSelect={() => {
setOpen(false)
void navigate({
to: '/groups/$groupId',
params: { groupId: String(group.id) },
})
}}
>
<FolderTreeIcon />
<span>{group.name}</span>
</CommandItem>
))}
</CommandGroup>
@@ -83,8 +83,9 @@ export function DataFreshness() {
size="sm"
checked={live}
onCheckedChange={handleCheckedChange}
aria-label={live ? 'Выключить автообновление' : 'Включить автообновление'}
aria-label="Автообновление"
/>
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
</div>
)
}
@@ -1,5 +1,6 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { MenuIcon } from 'lucide-react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,7 +13,9 @@ import { Separator } from '@cfdm/ui/components/separator'
import { AppsMenu } from '@/components/layout/apps-menu'
import { DataFreshness } from '@/components/layout/data-freshness'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { Button } from '@cfdm/ui/components/button'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
@@ -45,6 +48,15 @@ export function SiteHeader() {
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Меню и поиск"
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
>
<MenuIcon aria-hidden />
</Button>
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
@@ -8,7 +8,6 @@ import {
ShieldCheck,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { useSystemAttention } from '@/hooks/use-system-attention'
import type { SystemIncident } from '@/lib/system-attention'
import { cn } from '@cfdm/ui/lib/utils'
@@ -168,7 +167,11 @@ export function SystemMonitorPopover() {
render={
<button
type="button"
aria-label="Монитор системы"
aria-label={
spiking
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
: 'Состояние системы: норма'
}
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
@@ -188,14 +191,15 @@ export function SystemMonitorPopover() {
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
<span className="text-foreground hidden text-xs font-medium sm:inline">
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</span>
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
{attention.servicesDown}
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
{attention.servicesDegraded + attention.certWarnings}
</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
+101
View File
@@ -41,6 +41,14 @@ import {
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
import {
readContrast,
readDensity,
writeContrast,
writeDensity,
type ContrastMode,
type DensityMode,
} from '@/lib/appearance-mode'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
)
}
function SegmentedChoice<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: readonly { value: T; label: string }[]
onChange: (value: T) => void
}) {
return (
<div
role="radiogroup"
aria-label={label}
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{options.map((option) => {
const isActive = value === option.value
return (
<Button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
size="xs"
variant={isActive ? 'secondary' : 'ghost'}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
)
})}
</div>
)
}
function AppearanceToggles() {
const [contrast, setContrast] = useState<ContrastMode>('standard')
const [density, setDensity] = useState<DensityMode>('normal')
const [mounted, setMounted] = useState(false)
useEffect(() => {
setContrast(readContrast())
setDensity(readDensity())
setMounted(true)
}, [])
if (!mounted) return null
return (
<>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<span className="text-sm">Контраст</span>
<div className="ml-auto">
<SegmentedChoice
label="Контраст"
value={contrast}
options={[
{ value: 'standard', label: 'Обычный' },
{ value: 'high', label: 'Высокий' },
]}
onChange={(next) => {
setContrast(next)
writeContrast(next)
}}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<span className="text-sm">Плотность</span>
<div className="ml-auto">
<SegmentedChoice
label="Плотность"
value={density}
options={[
{ value: 'normal', label: 'Обычная' },
{ value: 'compact', label: 'Компакт' },
]}
onChange={(next) => {
setDensity(next)
writeDensity(next)
}}
/>
</div>
</DropdownMenuItem>
</>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
@@ -202,6 +302,7 @@ export function NavUser() {
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
<AppearanceToggles />
</DropdownMenuGroup>
<DropdownMenuSeparator />
+3 -1
View File
@@ -16,7 +16,9 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div>
)
}
@@ -50,7 +50,7 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
'flex min-w-0 flex-col items-stretch gap-4',
className,
)}
>
@@ -1,4 +1,5 @@
import { useMemo, useEffect, useRef } from 'react'
import { ActivityIcon } from 'lucide-react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
</Frame>
)
}
/**
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
* Preview: https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/frame
*/
export function NodeHealthChart() {
return (
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Здоровье нод</FrameTitle>
<FrameDescription>
Healthy / Unhealthy по проверкам health-check · последние 24 часа
</FrameDescription>
</FrameHeader>
<FramePanel className="flex min-h-52 flex-col">
<EmptyState
icon={ActivityIcon}
title="Нет ряда за 24 часа"
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
stackedIcon={false}
centered={false}
/>
</FramePanel>
</Frame>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@ export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent
export { ServiceHealthMonitor } from './service-health-monitor'
export { ServiceFailoverPanel } from './service-failover-panel'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { CertStatusChart, GroupDomainsChart, NodeHealthChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
KpiStatGrid,
@@ -4,8 +4,7 @@ import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -40,14 +39,18 @@ export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const DEFAULT_ICON_CLASS = 'text-muted-foreground'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
const HINT_VARIANT: Record<KpiStatVariant, 'outline' | 'warning-light' | 'destructive-light'> = {
default: 'outline',
warning: 'warning-light',
destructive: 'destructive-light',
}
/** stats-12 tile: https://reui.io/preview/base/stats-12 */
const STAT_ICON_CLASS =
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5'
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -67,7 +70,11 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
<Badge
variant={HINT_VARIANT[item.variant ?? 'default']}
size="sm"
className="max-w-full truncate"
>
{item.hint}
</Badge>
)
@@ -78,44 +85,31 @@ function resolveFooter(item: KpiStatItem): ReactNode {
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
<div className="relative z-10 flex h-full min-w-0 flex-col items-start gap-6">
{item.icon ? (
<IconTile
variant="elevated"
<Item
className={cn(STAT_ICON_CLASS, item.iconClassName ?? DEFAULT_ICON_CLASS)}
aria-hidden="true"
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</IconTile>
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="text-foreground min-w-0 text-2xl leading-none font-bold break-all tabular-nums">
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
<div className="text-muted-foreground min-w-0 text-sm font-medium">
{item.label}
</div>
</div>
{footer}
</div>
)
}
@@ -196,24 +190,28 @@ export function KpiStatCard({
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function statGridClass(count: number) {
if (count >= 4) return 'grid-cols-1 gap-5 @xl:grid-cols-2 @5xl:grid-cols-4'
if (count === 3) return 'grid-cols-1 gap-5 @3xl:grid-cols-3'
return 'grid-cols-1 gap-5 @3xl:grid-cols-2'
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
<div className={cn('@container grid w-full', statGridClass(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Frame key={index}>
<FramePanel className="flex flex-col items-start gap-6">
<Skeleton className="size-10.5 rounded-lg" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-5 w-28 rounded-full" />
</FramePanel>
</Frame>
))}
</div>
</Frame>
)
}
@@ -232,44 +230,10 @@ interface KpiStatGridProps {
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* KPI grid from stats-12: one Frame per metric, icon tile, value, label, badge.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/application/stats/stats-12
*/
export function KpiStatGrid({
items,
@@ -301,27 +265,19 @@ export function KpiStatGrid({
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
<div className={cn('grid', statGridClass(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
<KpiStatCardTile
key={kpiStatItemKey(item, index)}
item={item}
embedded={embedded}
/>
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
@@ -63,22 +63,11 @@ export function OpsDashboard({
{afterKpi}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
{charts}
</section>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">
{queueDescription}
</p>
) : null}
</div>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
{queue}
</section>
</div>
@@ -8,9 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
@@ -20,6 +18,7 @@ export interface QuickActionItem {
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
badgeVariant?: 'info-light' | 'warning-light'
}
interface QuickActionGridProps {
@@ -29,39 +28,10 @@ interface QuickActionGridProps {
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
* Quick links from card-12, with the artboard title, description and «Перейти».
* Preview: https://reui.io/preview/base/card-12
* Docs: https://reui.io/blocks/application/card/card-12
*/
export function QuickActionGrid({
actions,
@@ -72,28 +42,37 @@ export function QuickActionGrid({
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<Frame className={cn('w-full', className)}>
{title || description ? (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
) : null}
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
key={action.id}
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
>
<QuickActionBody action={action} />
</Link>
<FramePanel className="flex h-full flex-col gap-3 p-3.5">
<span className={cn('[&_svg]:size-5', action.iconClassName)} aria-hidden="true">
{action.icon}
</span>
<div className="flex items-start justify-between gap-2">
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
{action.title}
</h3>
<Badge variant={action.badgeVariant ?? 'info-light'} size="sm">
Перейти
</Badge>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{action.description}</p>
</FramePanel>
</Link>
))}
</div>
</Frame>
@@ -1,104 +1,67 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
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 { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
const SETTINGS_TABS = [
{ to: '/settings/appearance', label: 'Внешний вид' },
{ to: '/settings/health', label: 'Health-check' },
{ to: '/settings/integrations', label: 'Интеграции' },
] as const
type SettingsTab = (typeof SETTINGS_TABS)[number]['to']
function activeTab(pathname: string): SettingsTab {
const match = SETTINGS_TABS.find(
(tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`),
)
return match?.to ?? '/settings/appearance'
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'health',
to: '/settings/health',
label: 'Health-check',
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
/**
* Настройки CfSettings: вкладки внутри страницы, не боковой rail.
* Строки секций — settings-3 SettingField / SettingRow.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/settings-16
* Docs: https://reui.io/blocks/application/settings
*/
export function SettingsShell() {
const navigate = useNavigate()
const pathname = useRouterState({ select: (state) => state.location.pathname })
const value = activeTab(pathname)
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
<PageHeader
title="Настройки"
description="Интерфейс, health-check и интеграции"
/>
<Tabs
value={value}
onValueChange={(next) => {
const tab = SETTINGS_TABS.find((item) => item.to === next)
if (!tab) return
void navigate({ to: tab.to })
}}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
<TabsList variant="line" className="w-full justify-start gap-5">
{SETTINGS_TABS.map((tab) => (
<TabsTrigger
key={tab.to}
value={tab.to}
nativeButton={false}
render={<Link to={tab.to} />}
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3"
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
export type { SettingsTab as SettingsTabConfig }
+33 -28
View File
@@ -3,28 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
* So variant="inverse" re-points every panel from one place, and a consumer's
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
// Each rung is the radius that style's own shadcn Card resolves through, so
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
// var(--radius-none): no such token exists, and it only reached 0 by being
// invalid, which also left --frame-radius empty for anything reading it.
"[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
// Luma alone re-times the frame: wider gap and padding, roomier bars.
"",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric: the panel nests inside the frame's corner rather than copying
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
// that keeps the two arcs parallel. `ghost` drops the border term (no
// border), `dense` pins it to the frame radius (panels sit flush).
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
@@ -32,14 +34,18 @@ const frameVariants = cva(
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
// against a body py of 2/3.5/4/5, while px stays level with the body so
// header, content and footer left-align. xs floors at 0.5 (2px), below
// which it stops reading as padding. No style-*.css overrides these.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
@@ -55,8 +61,9 @@ const frameVariants = cva(
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
// Parent selectors, not CSS vars: these are positional. Panels are
// pulled flush (-mx-px), so corners align with the frame's own radius.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
@@ -98,13 +105,11 @@ function FramePanel({
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
/* `fit` sizes the panel to its content; otherwise it grows to fill
the frame. */
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
@@ -100,25 +100,31 @@ export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
isLoading={logQuery.isFetching}
/>
) : (
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Задержка и ошибки</FrameTitle>
<FrameDescription>Проба health-check · последние 24 часа</FrameDescription>
</FrameHeader>
<FramePanel>
<EmptyState
icon={ActivityIcon}
title="Нет ряда задержки"
description="Журнал health-log не содержит замеров latency. График появится после проверок с задержкой."
title="Нет ряда за 24 часа"
description="Журнал health-log не содержит замеров задержки. Точки не подставляются."
stackedIcon={false}
centered={false}
/>
)}
{history.length > 0 ? (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>События журнала смены адресов</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={[]} history={history} />
</FramePanel>
</Frame>
)}
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>История failover</FrameTitle>
<FrameDescription>Переключения между нодами</FrameDescription>
</FrameHeader>
<FramePanel>
{history.length > 0 ? (
<FailoverTimeline events={[]} history={history} />
) : (
<EmptyState
icon={UnplugIcon}
@@ -128,6 +134,8 @@ export function ServiceHealthSection({ serviceId }: { serviceId: string }) {
centered={false}
/>
)}
</FramePanel>
</Frame>
</div>
</QueryState>
)
+6 -1
View File
@@ -1,7 +1,12 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import { applyAppearance, readContrast, readDensity } from '@/lib/appearance-mode'
export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
applyAppearance(readContrast(), readDensity())
}, [])
return (
<NextThemesProvider
attribute="class"
+44
View File
@@ -0,0 +1,44 @@
const CONTRAST_KEY = 'cfdm.contrast'
const DENSITY_KEY = 'cfdm.density'
export type ContrastMode = 'standard' | 'high'
export type DensityMode = 'normal' | 'compact'
export function readContrast(): ContrastMode {
try {
return localStorage.getItem(CONTRAST_KEY) === 'high' ? 'high' : 'standard'
} catch {
return 'standard'
}
}
export function readDensity(): DensityMode {
try {
return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'normal'
} catch {
return 'normal'
}
}
export function applyAppearance(contrast: ContrastMode, density: DensityMode) {
document.documentElement.dataset.contrast = contrast
document.documentElement.dataset.density = density
}
export function writeContrast(contrast: ContrastMode) {
try {
localStorage.setItem(CONTRAST_KEY, contrast)
} catch {
// хранилище недоступно
}
applyAppearance(contrast, readDensity())
}
export function writeDensity(density: DensityMode) {
try {
localStorage.setItem(DENSITY_KEY, density)
} catch {
// хранилище недоступно
}
applyAppearance(readContrast(), density)
}
+1
View File
@@ -8,6 +8,7 @@ import { Toaster } from '@cfdm/ui/components/sonner'
import { routeTree } from './routeTree.gen'
import { queryClient } from './lib/queryClient'
import '@cfdm/ui/globals.css'
import '@/styles/appearance-modes.css'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
debugAgentLog('main.tsx:boot', 'app boot', {
+1 -1
View File
@@ -73,7 +73,7 @@ function CertificatesPage() {
isLoading={checkMutation.isPending}
loadingLabel="Проверка…"
>
Запустить проверку
Проверить все
</LoadingButton>
)
+53 -85
View File
@@ -1,14 +1,13 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { CircleAlertIcon, GlobeIcon, SearchIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput, type DomainListItem } from '@/lib/schemas'
import { type BulkUpdateDomainsInput, type DomainListItem } from '@/lib/schemas'
import {
bulkUpdateDomains,
dnsKeys,
@@ -35,8 +34,8 @@ import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
@@ -56,9 +55,10 @@ export const Route = createFileRoute('/_auth/domains/')({
})
function DomainsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [groupOpen, setGroupOpen] = useState(false)
const [groupValue, setGroupValue] = useState('none')
const selectedIdsRef = useRef<string[]>([])
const [syncOpen, setSyncOpen] = useState(false)
const [importGroupId, setImportGroupId] = useState('')
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const [activeTab, setActiveTab] = useState('all')
@@ -84,24 +84,7 @@ function DomainsPage() {
const filterFields = useDomainFilterFields(groupItems)
const form = useForm<CreateDomainInput>({
resolver: zodResolver(createDomainSchema),
defaultValues: { zone_name: '', group_id: '' },
})
const createMutation = useMutation({
mutationFn: (body: { zone_name: string; group_id?: number }) =>
api.post('/api/v1/domains', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
form.reset({ zone_name: '', group_id: importGroupId })
setSheetOpen(false)
toast.success('Домен импортирован')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
},
})
const form = useForm({ defaultValues: { group_id: '' } })
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
@@ -149,25 +132,20 @@ function DomainsPage() {
enableSelection: true,
})
const handleCreate = (values: CreateDomainInput) => {
createMutation.mutate({
zone_name: values.zone_name.trim(),
group_id: importGroupId ? Number(importGroupId) : undefined,
})
}
const handleImportGroupChange = (value: string | null) => {
const next = value === 'none' || !value ? '' : value
setImportGroupId(next)
form.setValue('group_id', next)
}
const freshness = domainFreshnessLine(domains)
function openGroupSheet() {
if (selectedIdsRef.current.length === 0) {
toast.message('Выберите домены в таблице')
return
}
setGroupOpen(true)
}
const primaryAction = (
<>
<Button type="button" variant="outline" onClick={() => setSheetOpen(true)}>
Импортировать домен
<Button type="button" variant="outline" onClick={openGroupSheet}>
Сменить группу
</Button>
<LoadingButton
type="button"
@@ -184,16 +162,17 @@ function DomainsPage() {
<PageShell>
<PageHeader
title="Домены"
description={
<span className="flex flex-col gap-1">
<span>Импортированные зоны Cloudflare</span>
{freshness ? (
<span className="tabular-nums">{freshness}</span>
) : null}
</span>
}
description="Импортированные зоны Cloudflare"
actions={primaryAction}
/>
{freshness ? (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Badge variant="success-light" size="sm">
Cloudflare
</Badge>
<span className="tabular-nums">{freshness}</span>
</div>
) : null}
<DomainKpiStats
domains={domains}
activeTab={activeTab}
@@ -248,7 +227,9 @@ function DomainsPage() {
}}
onFilteredEmptyReset={() => setActiveTab('all')}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
selectionToolbar={({ selectedIds, clearSelection }) => {
selectedIdsRef.current = selectedIds
return (
<DomainsBulkToolbar
count={selectedIds.length}
isPending={bulkMutation.isPending}
@@ -259,21 +240,10 @@ function DomainsPage() {
{ onSuccess: () => clearSelection() },
)
}}
onSetEnvironment={(environment) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), environment },
{ onSuccess: () => clearSelection() },
)
}}
onAddTag={(tag) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), tags_add: [tag] },
{ onSuccess: () => clearSelection() },
)
}}
onClear={clearSelection}
/>
)}
)
}}
emptyState={{
icon: GlobeIcon,
title: 'Нет доменов',
@@ -292,43 +262,41 @@ function DomainsPage() {
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title="Импорт домена"
description="Добавить зону из аккаунта Cloudflare в менеджер"
open={groupOpen}
onOpenChange={setGroupOpen}
title="Сменить группу"
description="Группа применится к выбранным доменам"
form={form}
onSubmit={handleCreate}
onSubmit={() => {
const ids = selectedIdsRef.current.map(Number)
if (ids.length === 0) return
bulkMutation.mutate(
{
ids,
group_id: groupValue === 'none' ? null : Number(groupValue),
},
{ onSuccess: () => setGroupOpen(false) },
)
}}
footer={
<LoadingButton
type="submit"
className="w-full"
isLoading={createMutation.isPending}
loadingLabel="Импорт…"
isLoading={bulkMutation.isPending}
loadingLabel="Применение…"
>
Импортировать
Применить
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple
label="Имя зоны"
htmlFor="zone_name"
error={form.formState.errors.zone_name}
>
<Input
id="zone_name"
placeholder="example.com"
{...form.register('zone_name')}
aria-invalid={!!form.formState.errors.zone_name}
/>
</FormFieldSimple>
<FormFieldSimple label="Группа" htmlFor="import_group_id">
<FormFieldSimple label="Группа" htmlFor="change_group_id" hint="Домены без группы можно оставить без изменений">
<Select
items={groupItems}
value={importGroupId || 'none'}
onValueChange={handleImportGroupChange}
value={groupValue}
onValueChange={(value) => setGroupValue(value ?? 'none')}
>
<SelectTrigger id="import_group_id" className="w-full">
<SelectTrigger id="change_group_id" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
+3 -11
View File
@@ -211,15 +211,7 @@ function GroupsPage() {
</Button>
)
const headerActions = (
<>
{createButton}
<CatalogBoardToggle
view={view}
onViewChange={setView}
/>
</>
)
const headerActions = createButton
if (isLoading) {
return (
@@ -332,8 +324,8 @@ function GroupsPage() {
data={catalogRows}
getRowId={(row) => String(row.id)}
emptyState={{
title: 'Группы не найдены',
description: 'Создайте группу для организации доменов.',
title: 'Нет групп',
description: 'Создайте первую группу и назначьте в неё домены.',
action: createButton,
}}
/>
+127 -59
View File
@@ -1,14 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FolderTreeIcon, GlobeIcon, HashIcon } from 'lucide-react'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GlobeIcon, HashIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import {
bulkUpdateDomains,
domainKeys,
domainsListQueryOptions,
groupDetailQueryOptions,
groupKeys,
} from '@/queries'
import { api } from '@/lib/api-client'
import type { CreateGroupInput } from '@/lib/schemas'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
import { FormSheet } from '@/components/form-sheet'
import { PageShell } from '@/components/page-shell'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import { DetailPanel, KpiStatGrid, ResourcePage } from '@/components/reui-kit'
import {
createDefaultGroupDomainFilters,
groupDomainFilterFieldValue,
@@ -52,55 +61,51 @@ function GroupDetailPage() {
const filterFields = useGroupDomainFilterFields()
const columns = useGroupDomainColumns()
const queryClient = useQueryClient()
const [editOpen, setEditOpen] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [picked, setPicked] = useState<number[]>([])
const addForm = useForm({ defaultValues: { ids: '' } })
const allDomains = useQuery({ ...domainsListQueryOptions(), enabled: addOpen })
const ungrouped = (allDomains.data ?? []).filter((domain) => domain.group_id == null)
const updateMutation = useMutation({
mutationFn: (body: CreateGroupInput) => api.patch(`/api/v1/groups/${id}`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
setEditOpen(false)
toast.success('Группа сохранена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const addMutation = useMutation({
mutationFn: (ids: number[]) => bulkUpdateDomains({ ids, group_id: id }),
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: domainKeys.all })
void queryClient.invalidateQueries({ queryKey: groupKeys.all })
setPicked([])
setAddOpen(false)
toast.success(`Добавлено: ${data.updated}`)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось добавить домены')
},
})
const headerActions = (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
К группам
<>
<Button type="button" variant="outline" onClick={() => setEditOpen(true)} disabled={!group}>
Изменить
</Button>
<Button type="button" onClick={() => setAddOpen(true)} disabled={!group}>
Добавить домены
</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 (
<PageShell>
<DetailPanel>
@@ -108,14 +113,31 @@ function GroupDetailPage() {
title={group?.name ?? 'Группа'}
description={
group
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
? `Группа доменов · slug ${group.slug} · ${group.domain_count} домена`
: 'Домены в группе'
}
actions={headerActions}
/>
{!isLoading && group ? (
<DetailPanel.Metrics cards={metricCards} />
<KpiStatGrid
cards={[
{
id: 'domains',
icon: <GlobeIcon aria-hidden />,
label: 'Домены',
value: group.domain_count,
hint: 'в группе',
},
{
id: 'slug',
icon: <HashIcon aria-hidden />,
label: 'Slug',
value: group.slug,
hint: 'для API',
},
]}
/>
) : null}
<DetailPanel.Section>
@@ -140,21 +162,67 @@ function GroupDetailPage() {
}}
emptyState={{
title: 'В группе нет доменов',
description:
'Перетащите домены на канбане групп или назначьте группу при импорте',
description: 'Добавьте домены, которые ещё не назначены в группу.',
action: (
<Button
variant="outline"
nativeButton={false}
render={<Link to="/groups" />}
>
Открыть канбан групп
<Button type="button" variant="outline" onClick={() => setAddOpen(true)}>
Добавить домены
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
<DomainGroupEditSheet
mode="edit"
group={group ?? null}
open={editOpen}
isSaving={updateMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_groupId, body) => updateMutation.mutate(body)}
/>
<FormSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить домены"
description="Только домены без группы"
form={addForm}
onSubmit={() => {
if (picked.length === 0) return
addMutation.mutate(picked)
}}
footer={
<Button type="submit" disabled={picked.length === 0 || addMutation.isPending}>
Добавить {picked.length || ''}
</Button>
}
>
<div className="flex flex-col gap-2">
{ungrouped.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет доменов без группы</p>
) : (
ungrouped.map((domain) => {
const on = picked.includes(domain.id)
return (
<Button
key={domain.id}
type="button"
variant={on ? 'secondary' : 'outline'}
className="justify-start"
onClick={() =>
setPicked((current) =>
on ? current.filter((item) => item !== domain.id) : [...current, domain.id],
)
}
>
{domain.zone_name}
</Button>
)
})
)}
</div>
</FormSheet>
</PageShell>
)
}
+6 -54
View File
@@ -1,6 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
ActivityIcon,
@@ -14,11 +14,9 @@ import {
} from 'lucide-react'
import {
certificatesQueryOptions,
certSummaryQueryOptions,
dnsKeys,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
opsSummaryQueryOptions,
serviceGroupsQueryOptions,
subdomainKeys,
@@ -27,8 +25,7 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import {
CertStatusChart,
GroupDomainsChart,
NodeHealthChart,
OpsDashboard,
QuickActionGrid,
AttentionQueue,
@@ -39,7 +36,6 @@ import {
type QuickActionItem,
} from '@/components/reui-kit'
import { Button } from '@cfdm/ui/components/button'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client'
import type { ServiceView } from '@/lib/schemas'
@@ -47,8 +43,6 @@ export const Route = createFileRoute('/_auth/')({
loader: ({ context: { queryClient } }) =>
Promise.allSettled([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(certSummaryQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(certificatesQueryOptions()),
queryClient.ensureQueryData(opsSummaryQueryOptions()),
@@ -69,14 +63,12 @@ function DashboardPage() {
error: domainsQueryError,
refetch: refetchDomains,
} = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const {
data: certs,
isError: certsFailed,
error: certsQueryError,
refetch: refetchCerts,
} = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const {
data: serviceData,
isError: servicesFailed,
@@ -131,7 +123,8 @@ function DashboardPage() {
to: '/services',
search: { domainId: undefined },
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-success',
iconClassName: 'text-warning',
badgeVariant: 'warning-light',
},
{
id: 'certs',
@@ -155,23 +148,7 @@ function DashboardPage() {
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
(serviceData?.ungrouped.length ?? 0)
const isLoading = domainsLoading || summaryLoading
const statusChartData = useMemo(
() => (summary ?? []).map(([status, count]) => ({ status, count })),
[summary],
)
const groupChartData = useMemo(() => {
const list = groups ?? []
return list
.map((g) => ({
name: g.name,
count: domains?.filter((d) => d.group_id === g.id).length ?? 0,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}, [groups, domains])
const isLoading = domainsLoading
const [nowMs] = useState(() => Date.now())
@@ -216,25 +193,6 @@ function DashboardPage() {
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
debugAgentLog(
'index.tsx:dashboard-data',
'dashboard chart inputs',
{
buildStamp: DEBUG_BUILD_STAMP,
summaryRaw: summary ?? null,
statusChartLen: statusChartData.length,
statusChartData,
groupChartLen: groupChartData.length,
groupChartData,
domainsLen: domains?.length ?? 0,
groupsLen: groups?.length ?? 0,
},
'C',
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
@@ -313,13 +271,7 @@ function DashboardPage() {
/>
) : null
}
charts={
<>
{/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */}
<CertStatusChart data={statusChartData} />
<GroupDomainsChart data={groupChartData} />
</>
}
charts={<NodeHealthChart />}
queue={
<AttentionQueue
columns={[
@@ -11,7 +11,6 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { LoadingButton } from '@/components/loading-button'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
@@ -217,12 +216,15 @@ function ServiceLayout() {
<DetailPanel>
<DetailPanel.Header
title={service.name}
description="Ноды, проверки и маршрутизация"
description={`Domain → Service → Node → Health → Failover · группа «${groups.find((group) => group.id === service.service_group_id)?.name ?? 'Без группы'}»`}
actions={
<>
<Button type="button" variant="outline" onClick={() => setEditOpen(true)}>
Изменить
</Button>
<Button type="button" onClick={() => setAddNodeOpen(true)}>
Добавить ноду
</Button>
{service.enabled ? (
<ConfirmDialog
title="Выключить сервис?"
@@ -240,9 +242,6 @@ function ServiceLayout() {
onConfirm={() => disableMutation.mutate()}
/>
) : null}
<Button type="button" onClick={() => setAddNodeOpen(true)}>
Добавить ноду
</Button>
</>
}
/>
@@ -253,7 +252,14 @@ function ServiceLayout() {
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: displayHealth === 'up' ? 'OK' : displayHealth,
value:
displayHealth === 'up'
? 'Up'
: displayHealth === 'degraded'
? 'Slow'
: displayHealth === 'down'
? 'Down'
: displayHealth,
variant:
displayHealth === 'down'
? 'destructive'
@@ -266,14 +272,17 @@ function ServiceLayout() {
: displayHealth === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
hint:
service.health_latency_ms != null
? `${service.health_latency_ms} мс`
: undefined,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
value: service.domains[0]?.fqdn ?? '—',
hint: service.domains[0]?.fqdn ? 'основной поддомен' : 'Нет привязанных FQDN',
},
{
id: 'ip',
+7 -23
View File
@@ -31,12 +31,8 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { CatalogBoardToggle } from '@/components/catalog-board-toggle'
import { type ServiceCatalogRow } from '@/components/columns/services-columns'
import {
ServicesAddMenu,
ServicesGroupedCatalog,
} from '@/components/services/services-grouped-catalog'
import { ServicesGroupedCatalog } from '@/components/services/services-grouped-catalog'
import { useServicesKanban } from '@/hooks/use-services-kanban'
import { useServicesSelection } from '@/hooks/use-services-selection'
import {
@@ -255,15 +251,6 @@ function ServicesPage() {
})
}
function setView(next: 'catalog' | 'board') {
navigate({
search: (prev) => ({
...prev,
view: next === 'board' ? 'board' : undefined,
}),
})
}
async function invalidateAll() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
@@ -568,17 +555,14 @@ function ServicesPage() {
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
const addMenu = (
<ServicesAddMenu
onAddService={() => handleOpenCreateService()}
onAddGroup={() => setCreateGroupSheetOpen(true)}
/>
)
const headerActions = (
<>
{addMenu}
<CatalogBoardToggle view={view} onViewChange={setView} />
<Button type="button" variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Создать группу сервисов
</Button>
<Button type="button" onClick={() => handleOpenCreateService()}>
Создать сервис
</Button>
</>
)
+16
View File
@@ -0,0 +1,16 @@
html[data-contrast='high'] {
--border: color-mix(in oklch, var(--foreground) 46%, var(--background));
--input: color-mix(in oklch, var(--foreground) 46%, var(--background));
--ring: var(--foreground);
--muted-foreground: color-mix(in oklch, var(--foreground) 78%, var(--background));
--sidebar-border: color-mix(in oklch, var(--foreground) 46%, var(--sidebar));
}
html[data-density='compact'] {
font-size: 14px;
}
html[data-density='compact'] #main-content > div {
gap: 0.75rem;
padding: 0.75rem;
}
+1 -1
View File
@@ -1,8 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Separator } from "@cfdm/ui/components/separator"
const buttonGroupVariants = cva(
-1
View File
@@ -1,6 +1,5 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const buttonVariants = cva(
+2 -1
View File
@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@cfdm/ui/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
-1
View File
@@ -1,5 +1,4 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
+2 -4
View File
@@ -1,9 +1,7 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Label } from "@cfdm/ui/components/label"
import { Separator } from "@cfdm/ui/components/separator"
@@ -106,7 +104,7 @@ function FieldLabel({
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
+3 -1
View File
@@ -1,7 +1,9 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Button } from "@cfdm/ui/components/button"
import { Input } from "@cfdm/ui/components/input"
import { Textarea } from "@cfdm/ui/components/textarea"
-1
View File
@@ -1,6 +1,5 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@cfdm/ui/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+1 -1
View File
@@ -2,8 +2,8 @@ import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { Separator } from "@cfdm/ui/components/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react"
"use client"
import * as React from "react"
import { cn } from "@cfdm/ui/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
-3
View File
@@ -1,8 +1,5 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@cfdm/ui/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
-1
View File
@@ -1,5 +1,4 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@cfdm/ui/lib/utils"
function Separator({
+3 -2
View File
@@ -1,5 +1,6 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@cfdm/ui/lib/utils"
function Switch({
@@ -14,7 +15,7 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
-1
View File
@@ -1,5 +1,4 @@
import * as React from "react"
import { cn } from "@cfdm/ui/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+1 -3
View File
@@ -1,11 +1,9 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { toggleVariants } from "@cfdm/ui/components/toggle"
const ToggleGroupContext = React.createContext<
-1
View File
@@ -1,6 +1,5 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const toggleVariants = cva(