feat(web): добавить поддержку быстрого доступа на дашборде и обновить настройки интерфейса
Docker / build (push) Failing after 19s

- Внедрён компонент QuickActionGrid для отображения часто используемых разделов на дашборде, управляемый настройкой showQuickActions.
- Добавлен новый параметр showQuickActions в схему настроек для управления видимостью быстрого доступа.
- Обновлён интерфейс настроек для включения переключателя отображения быстрого доступа.
- Добавлен компонент SystemMonitorPopover в заголовок приложения для мониторинга системы.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 20:32:59 +07:00
co-authored by Cursor
parent 9c43b45245
commit d52c633d90
15 changed files with 608 additions and 6 deletions
@@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
@@ -230,6 +231,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{stats.issuesCount} проблем
</Badge>
) : null}
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
@@ -0,0 +1,247 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Activity,
ListChecks,
RefreshCw,
Server,
Wallet,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
import { Progress } from '@cfdm/ui/components/progress'
import { api } from '@/lib/api-client'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { snapshotQueryOptions } from '@/queries/snapshot'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
function isStaleSync(lastAt: string | null | undefined): boolean {
if (!lastAt) return true
const ts = new Date(lastAt).getTime()
if (Number.isNaN(ts)) return true
return Date.now() - ts > 24 * 60 * 60 * 1000
}
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
export function SystemMonitorPopover() {
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
const syncQ = useQuery({
queryKey: ['sync', 'status'],
queryFn: () => api.fetchSyncStatus() as Promise<Array<{ status?: string; ok?: boolean }>>,
refetchInterval: 30_000,
})
const notifyQ = useQuery({
queryKey: ['notifications', 'log', 'monitor'],
queryFn: () => api.fetchNotificationLog(20),
refetchInterval: 30_000,
})
const stats = statsQ.data
const vps = snapQ.data?.vps ?? []
const downCount = vps.filter((v) => {
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
return status === 'down'
}).length
const issuesCount = stats?.issuesCount ?? 0
const runwayDays = stats?.minRunwayDays
const runwayLow = runwayDays != null && runwayDays < 14
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
const staleSync =
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
const recentSyncFailed = (syncQ.data ?? []).some(
(row) =>
String(row.status ?? '').toLowerCase() === 'failed' ||
String(row.status ?? '').toLowerCase() === 'error' ||
row.ok === false,
)
const syncAlert = staleSync || recentSyncFailed
const failedNotifications = (notifyQ.data ?? []).filter(
(n) => String(n.status ?? '').toLowerCase() === 'failed',
).length
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'sync',
label: 'Синк',
value: syncAlert ? '!' : 'OK',
unit: '',
percent: syncAlert ? 35 : 100,
icon: <RefreshCw aria-hidden />,
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
alert: syncAlert,
},
{
id: 'inventory',
label: 'Инвентарь',
value: String(issuesCount),
unit: 'шт.',
percent: Math.min(100, issuesCount * 15),
icon: <ListChecks aria-hidden />,
tone: issuesCount > 0 ? 'warning' : 'success',
alert: issuesCount > 0,
},
{
id: 'runway',
label: 'Runway',
value: runwayDays != null ? String(runwayDays) : '—',
unit: runwayDays != null ? 'дн' : '',
percent:
runwayDays == null
? 0
: Math.min(100, Math.round((runwayDays / 30) * 100)),
icon: <Wallet aria-hidden />,
tone: runwayLow || lowBalance ? 'warning' : 'success',
alert: runwayLow || lowBalance,
},
{
id: 'vps-down',
label: 'VPS down',
value: String(downCount),
unit: 'шт.',
percent: Math.min(100, downCount * 25),
icon: <Server aria-hidden />,
tone: downCount > 0 ? 'destructive' : 'success',
alert: downCount > 0,
},
],
[downCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
)
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
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',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<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]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
API:{' '}
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
{' · '}
Уведомлений с ошибкой:{' '}
<span className="text-foreground font-medium tabular-nums">{failedNotifications}</span>
{stats?.lastGlobalSyncAt ? (
<>
{' · '}
Синк:{' '}
<span className="text-foreground font-medium tabular-nums">
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
</span>
</>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
@@ -6,6 +6,10 @@ export {
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export {
QuickActionGrid,
type QuickActionItem,
} from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,113 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
export interface QuickActionItem {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
) : 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
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container 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))}>
{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
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
+1
View File
@@ -112,6 +112,7 @@ export const settingsSchema = z.object({
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
'Ключи кастомных полей должны быть уникальными',
),
showQuickActions: z.boolean().optional().default(true),
})
export {
+63 -4
View File
@@ -13,11 +13,15 @@ import {
CoinsIcon,
ClockIcon,
DownloadIcon,
CreditCardIcon,
ChartColumnBigIcon,
ChartBarIcon,
PlugIcon,
} from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit'
import { OpsDashboard, QuickActionGrid, type KpiStatCard, type QuickActionItem } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types'
@@ -45,6 +49,57 @@ import type { Vps } from '@/types/entities'
const DASHBOARD_TAB_TRIGGER_CLASS =
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
{
id: 'vps',
title: 'VPS',
description: 'Список серверов, оплата и здоровье.',
to: '/vps',
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'accounts',
title: 'Аккаунты',
description: 'Аккаунты хостеров и баланс API.',
to: '/accounts',
icon: <WalletIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'payments',
title: 'Платежи',
description: 'Пополнения и оплаты VPS.',
to: '/payments',
icon: <CreditCardIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'reports',
title: 'Отчёты',
description: 'Сводки по расходам и балансам.',
to: '/reports',
icon: <ChartColumnBigIcon aria-hidden />,
iconClassName: 'text-warning',
},
{
id: 'resources',
title: 'Ресурсы',
description: 'CPU, RAM и диск по инвентарю.',
to: '/resources',
icon: <ChartBarIcon aria-hidden />,
iconClassName: 'text-focus',
},
{
id: 'integrations',
title: 'Интеграции',
description: 'App switcher и внешние связки.',
to: '/settings/integrations',
icon: <PlugIcon aria-hidden />,
iconClassName: 'text-muted-foreground',
},
]
export const Route = createFileRoute('/_auth/dashboard')({
loader: ({ context: { queryClient } }) =>
Promise.all([
@@ -401,10 +456,14 @@ function DashboardPage() {
}
/>
{snap.settings?.[0]?.showQuickActions !== false ? (
<QuickActionGrid
actions={DASHBOARD_QUICK_ACTIONS}
description="Частые разделы учёта и аналитики"
/>
) : null}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
Ресурсы
</Button>
<Button
variant="outline"
size="sm"
@@ -29,6 +29,7 @@ import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { FormField } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { NotificationLogRow, Settings } from '@/types/entities'
@@ -77,6 +78,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
webhookEnabled: s.webhookEnabled === true,
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
showQuickActions: s.showQuickActions !== false,
}
}
@@ -330,6 +332,34 @@ function SettingsPage() {
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Интерфейс</CardTitle>
<CardDescription>Блоки на дашборде</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FormField label="Быстрые действия" htmlFor="set-qa">
<Controller
control={form.control}
name="showQuickActions"
render={({ field }) => (
<div className="flex items-center gap-3">
<Switch
id="set-qa"
checked={field.value !== false}
onCheckedChange={field.onChange}
/>
<span className="text-muted-foreground text-sm">
Показывать на дашборде
</span>
</div>
)}
/>
</FormField>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Валюта и курсы</CardTitle>
+1
View File
@@ -134,6 +134,7 @@ export interface Settings {
integrationTokenSet?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
showQuickActions?: boolean
}
export interface VpsDomain {
+15 -1
View File
@@ -32,11 +32,25 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
| `OpsDashboard` | KPI + charts + attention queue |
| `QuickActionGrid` | KPI-like quick action tiles under/after KPI (gated by `showQuickActions`) |
| `OpsDashboard` | KPI + charts + attention queue (+ optional `afterKpi`) |
| `SettingsShell` | settings nav + Outlet |
| `DetailPanel` | detail Frame sections |
| `filter-utils` | apply/clear ReUI Filters |
## Dashboard layout
| App | Section order |
|-----|---------------|
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick_actions` (default `true`).
## System monitor
`SystemMonitorPopover` in app header next to `ModeToggle` (EvoBGP pattern: pill «Система» + Норма/Внимание). Preview shell: https://reui.io/preview/base/app-shell-12
## MCP workflow
1. MCP `user-reui``search` / `get_block` / `get_component` with `surface: "frame"`
+16 -1
View File
@@ -44,6 +44,7 @@ export type SettingsDto = Omit<
| 'integrationEnabled'
| 'customFields'
| 'appSwitcherJson'
| 'showQuickActions'
> & {
telegramBotTokenSet: boolean
integrationTokenSet: boolean
@@ -56,6 +57,7 @@ export type SettingsDto = Omit<
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
integrationEnabled: boolean
showQuickActions: boolean
notifyIntervalMinutes: number
uptimeCheckIntervalMinutes: number
customFields: unknown[]
@@ -81,7 +83,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
customFields = []
}
}
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
const { telegramBotToken, integrationToken, appSwitcherJson, showQuickActions: _showQa, ...rest } =
row
return {
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
@@ -95,6 +98,7 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
integrationEnabled: Boolean(row.integrationEnabled),
showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions),
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
customFields: Array.isArray(customFields) ? customFields : [],
@@ -135,6 +139,7 @@ interface SettingsInput {
integrationEnabled?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
showQuickActions?: boolean
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
@@ -238,6 +243,16 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
? r.integrationLastSyncAt || ''
: existing?.integrationLastSyncAt ?? '',
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
showQuickActions:
r.showQuickActions !== undefined
? r.showQuickActions
? 1
: 0
: existing?.showQuickActions == null
? 1
: existing.showQuickActions
? 1
: 0,
}
}
+1
View File
@@ -14,6 +14,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`,
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
]
+1
View File
@@ -133,6 +133,7 @@ export const settings = sqliteTable('settings', {
integrationEnabled: integer('integrationEnabled'),
integrationLastSyncAt: text('integrationLastSyncAt'),
cfdmApiUrl: text('cfdmApiUrl'),
showQuickActions: integer('showQuickActions'),
})
export const vpsDomains = sqliteTable('vps_domains', {
@@ -27,6 +27,7 @@ export const settingsSchema = z.object({
appSwitcher: appSwitcherConfigSchema.optional(),
integrationToken: z.string().optional(),
integrationEnabled: z.boolean().optional(),
showQuickActions: z.boolean().optional(),
})
export type Settings = z.infer<typeof settingsSchema>
+81
View File
@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@cfdm/ui/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@cfdm/ui/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
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",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }