fix(design-preview): плитки приложений внутри рамки и стиль MikrotikManager v2
QuickActionGrid получил autoFit: Frame с отступами и сетка auto-fit от 17rem, плитки больше не вылетают за границы. Центр состояния в шапке, меню пользователя (тема, плотность, контраст), точки проблем в меню, размытая шапка, высокий контраст и видимый фокус приведены к макету v2. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
8bbaab62e4
commit
60e0b886fc
5 files changed
+162
-177
No files matched your search
@@ -33,6 +33,8 @@ interface QuickActionGridProps {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
|
||||
autoFit?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||
@@ -108,18 +110,24 @@ export function QuickActionGrid({
|
||||
title = 'Быстрые действия',
|
||||
description,
|
||||
className,
|
||||
autoFit = false,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||
<Frame dense={!autoFit} 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))}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
|
||||
)}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
|
||||
@@ -11,9 +11,10 @@ html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-chil
|
||||
}
|
||||
|
||||
html[data-preview-contrast='high'] {
|
||||
--muted-foreground: color-mix(in oklch, var(--foreground) 82%, var(--background));
|
||||
--border: color-mix(in oklch, var(--foreground) 32%, var(--background));
|
||||
--input: color-mix(in oklch, var(--foreground) 40%, var(--background));
|
||||
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
|
||||
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
|
||||
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
|
||||
--ring: oklch(0.85 0.17 95);
|
||||
}
|
||||
|
||||
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
||||
@@ -26,3 +27,18 @@ html[data-preview-density],
|
||||
html[data-preview-density] body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html[data-preview-density] #main-content :focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-preview-density] #main-content *,
|
||||
html[data-preview-density] [data-slot='sidebar-wrapper'] * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,7 @@ export function AppLinksPage() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
title="Предпросмотр меню"
|
||||
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
|
||||
actions={shown.slice(0, 4).map((r) => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export function AppsPage() {
|
||||
</TabsList>
|
||||
<TabsContent value="all" className="flex flex-col gap-4">
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
title="Мои приложения"
|
||||
description="Клик открывает приложение · портал заново выпускает токен с актуальными правами"
|
||||
actions={allowed.map((app) => {
|
||||
@@ -60,6 +61,7 @@ export function AppsPage() {
|
||||
</TabsContent>
|
||||
<TabsContent value="no">
|
||||
<QuickActionGrid
|
||||
autoFit
|
||||
title="Недоступные приложения"
|
||||
description="Права не выданы · запросите у администратора"
|
||||
actions={denied.map((app) => {
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CloudIcon,
|
||||
ContrastIcon,
|
||||
FingerprintIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
RowsIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
ShieldIcon,
|
||||
SunIcon,
|
||||
UserPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { toast } from 'sonner'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -47,7 +41,6 @@ import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import { Kbd } from '@authportal/ui/components/kbd'
|
||||
@@ -71,6 +64,7 @@ import {
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Segmented } from './bits'
|
||||
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
||||
import { usePreviewStore } from './preview-store'
|
||||
|
||||
@@ -136,163 +130,90 @@ function PreviewAppSwitcher() {
|
||||
)
|
||||
}
|
||||
|
||||
const THEMES = [
|
||||
{ value: 'light', label: 'Светлая', icon: SunIcon },
|
||||
{ value: 'dark', label: 'Тёмная', icon: MoonIcon },
|
||||
{ value: 'system', label: 'Системная', icon: MonitorIcon },
|
||||
] as const
|
||||
|
||||
function Segmented<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: ReadonlyArray<{ value: T; label: string; icon?: typeof SunIcon }>
|
||||
onChange: (v: 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={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const active = value === o.value
|
||||
const Icon = o.icon
|
||||
return (
|
||||
<Button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={o.label}
|
||||
title={o.label}
|
||||
variant="ghost"
|
||||
size={Icon ? 'icon-xs' : 'xs'}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="size-3.5" aria-hidden /> : o.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||
|
||||
function PreviewNavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
const { density, setDensity, contrast, setContrast } = usePreviewStore()
|
||||
const navigate = useNavigate()
|
||||
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label="Профиль: Admin, [email protected]"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
aria-label="Профиль и настройки интерфейса: Admin, [email protected]"
|
||||
className="bg-muted/40 aria-expanded:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Admin</span>
|
||||
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
AD
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">Admin</span>
|
||||
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-64 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
|
||||
AD
|
||||
</div>
|
||||
<div className="grid leading-tight">
|
||||
<span className="text-sm font-semibold">Admin</span>
|
||||
<span className="text-muted-foreground text-xs">admin@shnt.top</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: '/design-preview/account' })}
|
||||
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-foreground flex items-center gap-2 py-2 font-normal">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">AD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Admin</span>
|
||||
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/account' })}>
|
||||
<FingerprintIcon aria-hidden />
|
||||
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
Безопасность
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={LABEL_CLASS}>Тема</span>
|
||||
<Segmented
|
||||
label="Тема"
|
||||
value={(mounted ? theme : 'system') ?? 'system'}
|
||||
options={THEMES}
|
||||
onChange={setTheme}
|
||||
options={['Тёмная', 'Светлая']}
|
||||
value={current === 'light' ? 1 : 0}
|
||||
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<RowsIcon aria-hidden />
|
||||
Плотность
|
||||
<div className="ml-auto">
|
||||
<span className={LABEL_CLASS}>Плотность</span>
|
||||
<Segmented
|
||||
label="Плотность"
|
||||
value={density}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Обычная' },
|
||||
{ value: 'compact', label: 'Компактная' },
|
||||
]}
|
||||
onChange={setDensity}
|
||||
options={['Обычная', 'Компактная']}
|
||||
value={density === 'compact' ? 1 : 0}
|
||||
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<ContrastIcon aria-hidden />
|
||||
Контраст
|
||||
<div className="ml-auto">
|
||||
<span className={LABEL_CLASS}>Контраст</span>
|
||||
<Segmented
|
||||
label="Контраст"
|
||||
value={contrast}
|
||||
options={[
|
||||
{ value: 'standard', label: 'Обычный' },
|
||||
{ value: 'high', label: 'Высокий' },
|
||||
]}
|
||||
onChange={setContrast}
|
||||
options={['Стандартный', 'Высокий']}
|
||||
value={contrast === 'high' ? 1 : 0}
|
||||
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => void navigate({ to: '/design-preview/login' })}>
|
||||
<LogOutIcon aria-hidden />
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: '/design-preview/login' })}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
@@ -308,19 +229,21 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<span className="relative flex shrink-0">
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn('ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2', DOT_CLASS[item.problem.tone])}
|
||||
className={cn(
|
||||
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
@@ -385,60 +308,95 @@ export function PreviewSidebar() {
|
||||
)
|
||||
}
|
||||
|
||||
const MONITOR_METRICS = [
|
||||
{ l: 'Пользователи без passkey', v: '3 из 7', p: 57, tone: 'warning' },
|
||||
{ l: 'Приложения доступны', v: '7 из 7', p: 100, tone: 'success' },
|
||||
{ l: 'OIDC-клиенты', v: '1 из 2', p: 50, tone: 'warning' },
|
||||
{ l: 'Сессии', v: '5', p: 40, tone: 'success' },
|
||||
] as const
|
||||
|
||||
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине шапки. */
|
||||
function MonitorPopover() {
|
||||
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Монитор Auth Portal: внимание, ${errors} ошибка, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 text-warning hover:bg-warning/15 focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-sm font-medium outline-none focus-visible:ring-2"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 @max-3xl/header:w-8 @max-3xl/header:border-transparent @max-3xl/header:bg-transparent @max-3xl/header:px-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon aria-hidden className="size-4" />
|
||||
<span className="hidden @md/header:inline">Внимание</span>
|
||||
<span className="bg-destructive inline-flex size-5 items-center justify-center rounded-full text-xs font-semibold text-white tabular-nums">
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium @4xl/header:inline">Внимание</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums @6xl/header:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white @3xl/header:inline-flex @6xl/header:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 @3xl/header:hidden" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">Монитор Auth Portal</span>
|
||||
<span className="text-muted-foreground text-[11px]">обновлено 14:02</span>
|
||||
<PopoverContent align="end" className="flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
<div className="grid leading-tight">
|
||||
<b className="text-sm font-semibold">Состояние системы</b>
|
||||
<span className="text-muted-foreground text-xs">обновлено 14:02</span>
|
||||
</div>
|
||||
<Badge variant="warning-light" size="sm">Внимание</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex gap-3 border-b px-3 py-2 text-xs">
|
||||
<span><b className="text-destructive font-semibold">{errors}</b> ошибка</span>
|
||||
<span><b className="text-warning font-semibold">{warnings}</b> предупреждения</span>
|
||||
<span><b className="text-success font-semibold">{MONITOR_ITEMS.length - bad.length}</b> в норме</span>
|
||||
</div>
|
||||
<ul className="flex flex-col">
|
||||
{MONITOR_ITEMS.map((m) => (
|
||||
<li key={m.id} className="border-border border-b last:border-b-0">
|
||||
<Link to={m.to} className="hover:bg-accent flex items-start gap-2.5 px-3 py-2.5 outline-none focus-visible:bg-accent">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-1.5 size-2 shrink-0 rounded-full',
|
||||
m.tone === 'er' ? 'bg-destructive' : m.tone === 'wn' ? 'bg-warning' : 'bg-success',
|
||||
)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm font-medium">{m.title}</span>
|
||||
<span className="text-muted-foreground text-xs">{m.text}</span>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ n: errors, l: 'сбой', c: 'text-destructive' },
|
||||
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
|
||||
{ n: ok, l: 'в норме', c: 'text-success' },
|
||||
].map((x) => (
|
||||
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
|
||||
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
|
||||
{x.l}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] whitespace-nowrap">{m.age}</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
|
||||
<li key={m.id}>
|
||||
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
|
||||
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
|
||||
<span className="grid min-w-0 flex-1 leading-tight">
|
||||
<b className="truncate text-[13px] font-medium">{m.title}</b>
|
||||
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MONITOR_METRICS.map((m) => (
|
||||
<div key={m.l} className="bg-muted/55 flex flex-col gap-2 rounded-lg px-3 py-2.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs">{m.l}</span>
|
||||
<b className={cn('text-[15px] font-semibold tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
||||
</div>
|
||||
<div className="bg-muted-foreground/25 h-[3px] overflow-hidden rounded-full">
|
||||
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link to="/design-preview/logins" className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||
Журнал входов
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
@@ -476,7 +434,7 @@ export function PreviewHeader() {
|
||||
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
<header className="bg-background @container/header sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<header className="bg-background/80 @container/header sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
|
||||
Reference in new issue
Block a user