refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
This commit is contained in:
@@ -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