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:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:15:12 +07:00
1 parent 8bbaab62e4
commit 60e0b886fc
5 files changed
+180 -195

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}
+19 -3
View File
@@ -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) => {
+148 -190
View File
@@ -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}
>
<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 />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<Segmented
label="Тема"
value={(mounted ? theme : 'system') ?? 'system'}
options={THEMES}
onChange={setTheme}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<RowsIcon aria-hidden />
Плотность
<div className="ml-auto">
<Segmented
label="Плотность"
value={density}
options={[
{ value: 'comfortable', label: 'Обычная' },
{ value: 'compact', label: 'Компактная' },
]}
onChange={setDensity}
/>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<ContrastIcon aria-hidden />
Контраст
<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>
<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"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<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 />
Выйти
</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" />
{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>
<Icon className="size-4" />
<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>
<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 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>
))}
</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>
<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 text-[11px] whitespace-nowrap">{m.age}</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">