Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55eb2a6c89 |
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Boxes,
|
||||||
|
ListChecks,
|
||||||
|
Network,
|
||||||
|
ServerCog,
|
||||||
|
Share2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { dashboardKpiGridClassName, kpiCardContentClassName } from '@/lib/ui-surface'
|
||||||
|
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
|
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||||
|
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
type KpiCard = {
|
||||||
|
icon: ReactNode
|
||||||
|
iconClass: string
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
badge: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKpis({
|
||||||
|
modules,
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
jobs,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
modules: ModuleRow[]
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
jobs: JobRow[]
|
||||||
|
loading?: boolean
|
||||||
|
}): KpiCard[] {
|
||||||
|
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||||
|
const network = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
const peersEnabled = network.peersEnabled
|
||||||
|
const bgpPct =
|
||||||
|
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||||
|
const running = runningJobCount(jobs)
|
||||||
|
const failedJobs = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||||
|
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
icon: <Boxes aria-hidden />,
|
||||||
|
iconClass: 'text-primary',
|
||||||
|
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||||
|
label: 'Модули активны',
|
||||||
|
badge: (
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
{loading ? '…' : `${modules.length} всего`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Network aria-hidden />,
|
||||||
|
iconClass: 'text-info',
|
||||||
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
|
label: 'BGP готовность',
|
||||||
|
badge: (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
bgpPct !== null && bgpPct >= 90
|
||||||
|
? 'success-light'
|
||||||
|
: bgpPct !== null && bgpPct < 70
|
||||||
|
? 'warning-light'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading || bgpPct === null
|
||||||
|
? 'нет включённых пиров'
|
||||||
|
: `${network.peersEstablished} установлено`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Share2 aria-hidden />,
|
||||||
|
iconClass: 'text-success',
|
||||||
|
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||||
|
label: 'Пиры Established',
|
||||||
|
badge: (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ServerCog aria-hidden />,
|
||||||
|
iconClass: 'text-warning',
|
||||||
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
|
label: 'Спикеры online',
|
||||||
|
badge: (
|
||||||
|
<Badge
|
||||||
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading ? '…' : 'live-снимок'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ListChecks aria-hidden />,
|
||||||
|
iconClass: 'text-focus',
|
||||||
|
value: loading ? '—' : String(running),
|
||||||
|
label: 'Активные задачи',
|
||||||
|
badge: (
|
||||||
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
|
{loading ? '…' : `${jobs.length} в выборке`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <AlertTriangle aria-hidden />,
|
||||||
|
iconClass: 'text-destructive',
|
||||||
|
value: loading ? '—' : String(riskCount),
|
||||||
|
label: 'Риски',
|
||||||
|
badge: (
|
||||||
|
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: riskCount > 0
|
||||||
|
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||||
|
: 'в норме'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardKpiGrid({
|
||||||
|
modules,
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
jobs,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
modules: ModuleRow[]
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
jobs: JobRow[]
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="KPI обзора" className={dashboardKpiGridClassName}>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Card key={card.label} size="sm" className="gap-0">
|
||||||
|
<CardContent className={cn(kpiCardContentClassName, 'gap-3 p-4')}>
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-9 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
card.iconClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{card.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-foreground text-xl leading-none font-bold tabular-nums">
|
||||||
|
{card.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs font-medium">{card.label}</div>
|
||||||
|
</div>
|
||||||
|
{card.badge}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function SettingsSettingField({
|
|||||||
contentClassName,
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|||||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||||
import {
|
import {
|
||||||
chartPanelGridClassName,
|
chartPanelGridClassName,
|
||||||
|
dashboardKpiGridClassName,
|
||||||
dashboardMainSidebarClassName,
|
dashboardMainSidebarClassName,
|
||||||
kpiGridClassName,
|
kpiGridClassName,
|
||||||
} from '@/lib/ui-surface'
|
} from '@/lib/ui-surface'
|
||||||
@@ -24,9 +25,9 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
|||||||
export function AnalyticsDashboardSkeleton() {
|
export function AnalyticsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="@container flex flex-col gap-4">
|
<div className="@container flex flex-col gap-4">
|
||||||
<div className={kpiGridClassName}>
|
<div className={dashboardKpiGridClassName}>
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<Skeleton key={`kpi-${i}`} className="h-36 w-full rounded-xl" />
|
<Skeleton key={`kpi-${i}`} className="h-28 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const UI_SURFACE = 'card' as const
|
|||||||
/** Shared padding for KPI / metric tiles inside PanelCard. */
|
/** Shared padding for KPI / metric tiles inside PanelCard. */
|
||||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||||
|
|
||||||
|
/** Compact 6-tile KPI row on dashboard overview. */
|
||||||
|
export const dashboardKpiGridClassName =
|
||||||
|
'grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
|
||||||
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
||||||
export const kpiGridClassName =
|
export const kpiGridClassName =
|
||||||
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|||||||
|
|
||||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||||
import { DashboardKpiSparklineRow } from '@/components/dashboard/dashboard-kpi-sparkline-row'
|
import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
|
||||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||||
@@ -92,7 +92,7 @@ function DashboardComponent() {
|
|||||||
<AnalyticsDashboardSkeleton />
|
<AnalyticsDashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DashboardKpiSparklineRow
|
<DashboardKpiGrid
|
||||||
modules={modules}
|
modules={modules}
|
||||||
peers={peers}
|
peers={peers}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import { createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Moon, Save, Sun, SunMoon } from 'lucide-react'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
|
||||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Save } from 'lucide-react'
|
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { PanelCard } from '@/components/panel-card'
|
||||||
|
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||||
|
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { FieldGroup } from '@evobgp/ui/components/field'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@evobgp/ui/components/toggle-group'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
const THEME_SELECT_ITEMS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: 'light', label: 'Светлая' },
|
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||||
{ value: 'dark', label: 'Тёмная' },
|
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
||||||
{ value: 'system', label: 'Как в системе' },
|
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
@@ -66,6 +70,12 @@ function SettingsComponent() {
|
|||||||
void applyToken(DEV_API_TOKEN)
|
void applyToken(DEV_API_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showSessionRow = Boolean(session || sessionError)
|
||||||
|
const sessionErrorMessage =
|
||||||
|
sessionQueryError instanceof Error
|
||||||
|
? sessionQueryError.message
|
||||||
|
: 'Не удалось проверить сессию'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -76,18 +86,45 @@ function SettingsComponent() {
|
|||||||
{tokenRequired ? (
|
{tokenRequired ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать dev».
|
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||||
|
dev».
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Подключение к API"
|
title="Подключение к API"
|
||||||
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
description="Токен хранится только в этом браузере (localStorage)."
|
||||||
contentClassName="flex flex-col gap-4 py-4"
|
contentClassName="p-0"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
|
Использовать dev
|
||||||
|
</Button>
|
||||||
|
<LoadingButton onClick={saveTokenHandler}>
|
||||||
|
<Save />
|
||||||
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<FieldGroup className="gap-0">
|
||||||
<Label htmlFor="token">Токен для запросов</Label>
|
<SettingsSettingField
|
||||||
|
title="Токен для запросов"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||||
|
ключами tenant — в разделе{' '}
|
||||||
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Права доступа
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
badge={{ label: 'localStorage', variant: 'outline' }}
|
||||||
|
labelFor="token"
|
||||||
|
last={!showSessionRow}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="token"
|
id="token"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -96,47 +133,80 @@ function SettingsComponent() {
|
|||||||
onChange={(e) => setTokenValue(e.target.value)}
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
placeholder="dev или API-ключ"
|
placeholder="dev или API-ключ"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsSettingField>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<LoadingButton onClick={saveTokenHandler}>
|
{showSessionRow ? (
|
||||||
<Save />
|
<SettingsSettingField
|
||||||
Сохранить токен
|
title="Статус сессии"
|
||||||
</LoadingButton>
|
description={
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
session
|
||||||
Использовать dev
|
? 'Проверка токена через GET /v1/auth/session.'
|
||||||
</Button>
|
: 'Токен сохранён, но сессия не подтверждена API.'
|
||||||
</div>
|
}
|
||||||
{session ? (
|
badge={
|
||||||
<p className="text-xs text-muted-foreground">
|
session
|
||||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
? { label: session.role, variant: 'success-light' }
|
||||||
<code className="font-mono">{session.role}</code>.
|
: { label: 'ошибка', variant: 'destructive-light' }
|
||||||
</p>
|
}
|
||||||
) : null}
|
last
|
||||||
{sessionError ? (
|
>
|
||||||
<p className="text-xs text-destructive">
|
{session ? (
|
||||||
{sessionQueryError instanceof Error
|
<div className="text-muted-foreground space-y-1 text-sm">
|
||||||
? sessionQueryError.message
|
<p>
|
||||||
: 'Не удалось проверить сессию'}
|
Tenant:{' '}
|
||||||
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
|
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||||
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
</p>
|
||||||
</p>
|
<p>
|
||||||
|
Роль: <Badge variant="info-light" size="sm">{session.role}</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{sessionErrorMessage}. Для токена <code className="font-mono">dev</code> нужен
|
||||||
|
demo-seed (<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный
|
||||||
|
API.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingsSettingField>
|
||||||
) : null}
|
) : null}
|
||||||
|
</FieldGroup>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Оформление"
|
title="Оформление"
|
||||||
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
||||||
contentClassName="flex flex-col gap-2 py-4"
|
contentClassName="p-0"
|
||||||
>
|
>
|
||||||
<SelectField
|
<FieldGroup className="gap-0">
|
||||||
id="theme-select"
|
<SettingsSettingField
|
||||||
label="Тема"
|
title="Тема"
|
||||||
items={[...THEME_SELECT_ITEMS]}
|
description="Влияет на цветовую схему всех экранов в этом браузере."
|
||||||
value={theme ?? 'system'}
|
badge={{ label: 'мгновенно', variant: 'primary-light' }}
|
||||||
placeholder="Выберите тему"
|
last
|
||||||
triggerClassName="max-w-xs"
|
>
|
||||||
onValueChange={(v) => v && setTheme(v)}
|
<ToggleGroup
|
||||||
/>
|
multiple={false}
|
||||||
|
value={[theme ?? 'system']}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.length > 0) setTheme(value[0])
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Тема интерфейса"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
||||||
|
<Icon aria-hidden className="size-3.5" />
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingsSettingField>
|
||||||
|
</FieldGroup>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user