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