651 lines
28 KiB
TypeScript
651 lines
28 KiB
TypeScript
import { useMemo, useState, type ComponentType } from 'react'
|
|
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
|
import {
|
|
ActivityIcon,
|
|
ArrowRightIcon,
|
|
CheckIcon,
|
|
ChevronDownIcon,
|
|
ChevronsUpDownIcon,
|
|
CloudIcon,
|
|
FingerprintIcon,
|
|
GlobeIcon,
|
|
InfoIcon,
|
|
KeyRoundIcon,
|
|
ShieldCheckIcon,
|
|
LayoutDashboardIcon,
|
|
LogOutIcon,
|
|
SearchIcon,
|
|
ServerIcon,
|
|
StarIcon,
|
|
ShieldIcon,
|
|
MoonIcon,
|
|
PlayIcon,
|
|
PlusIcon,
|
|
} 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 {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
} from '@authportal/ui/components/breadcrumb'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@authportal/ui/components/dialog'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
} from '@authportal/ui/components/dropdown-menu'
|
|
import { Kbd } from '@authportal/ui/components/kbd'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
|
|
import { Separator } from '@authportal/ui/components/separator'
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuAction,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarSeparator,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from '@authportal/ui/components/sidebar'
|
|
import { cn } from '@authportal/ui/lib/utils'
|
|
import { Segmented } from '@/components/portal/bits'
|
|
import { FRESH_BY_PAGE, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
|
import { usePreviewStore } from './preview-store'
|
|
|
|
const APP_ICON: Record<AppId, typeof CloudIcon> = {
|
|
cfdm: CloudIcon,
|
|
vps: ServerIcon,
|
|
bgp: GlobeIcon,
|
|
fw: ShieldIcon,
|
|
dns: GlobeIcon,
|
|
cdn: CloudIcon,
|
|
mm: LayoutDashboardIcon,
|
|
}
|
|
|
|
function PreviewAppSwitcher() {
|
|
const { isMobile } = useSidebar()
|
|
return (
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
|
|
>
|
|
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
|
|
<ShieldCheckIcon className="size-4" />
|
|
</div>
|
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
<span className="truncate font-semibold">Auth Portal</span>
|
|
<span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
|
|
</div>
|
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="min-w-56 rounded-lg"
|
|
side={isMobile ? 'bottom' : 'right'}
|
|
align="start"
|
|
sideOffset={4}
|
|
>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
|
|
<DropdownMenuItem disabled>
|
|
<KeyRoundIcon />
|
|
Auth Portal
|
|
<CheckIcon className="ml-auto size-4" />
|
|
</DropdownMenuItem>
|
|
{APPS.map((app) => {
|
|
const Icon = APP_ICON[app.id]
|
|
return (
|
|
<DropdownMenuItem
|
|
key={app.id}
|
|
onClick={() => toast(`Открыть ${app.title}`, { description: `${app.url} · SSO handoff (макет)` })}
|
|
>
|
|
<Icon />
|
|
{app.title}
|
|
<span className="text-muted-foreground ml-auto font-mono text-[11px]">{app.id}</span>
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
)
|
|
}
|
|
|
|
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
|
|
|
function PreviewNavUser() {
|
|
const { isMobile } = useSidebar()
|
|
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>
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<SidebarMenuButton
|
|
size="lg"
|
|
aria-label="Профиль и настройки интерфейса: Admin, admin@shnt.top"
|
|
className="bg-muted/40 aria-expanded:bg-muted"
|
|
/>
|
|
}
|
|
>
|
|
<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="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>
|
|
)
|
|
}
|
|
|
|
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
|
|
|
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
|
const Icon = item.icon
|
|
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
|
return (
|
|
<SidebarMenuItem>
|
|
<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} />}
|
|
>
|
|
<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}
|
|
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
|
aria-pressed={pinned}
|
|
onClick={onTogglePin}
|
|
>
|
|
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
|
</SidebarMenuAction>
|
|
</SidebarMenuItem>
|
|
)
|
|
}
|
|
|
|
export function PreviewSidebar() {
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
const { pins, togglePin } = usePreviewStore()
|
|
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
|
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
|
|
return (
|
|
<Sidebar collapsible="icon">
|
|
<SidebarHeader className="pb-1.5">
|
|
<PreviewAppSwitcher />
|
|
</SidebarHeader>
|
|
<SidebarContent>
|
|
{pinned.length > 0 ? (
|
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{pinned.map((item) => (
|
|
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
) : null}
|
|
{NAV_GROUPS.map((group, gi) => (
|
|
<div key={group} className="flex flex-col">
|
|
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
|
|
<SidebarGroup className="py-0">
|
|
<SidebarGroupLabel
|
|
render={
|
|
<button
|
|
type="button"
|
|
aria-expanded={!closed[group]}
|
|
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
|
|
/>
|
|
}
|
|
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
|
|
>
|
|
{group}
|
|
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
|
|
</SidebarGroupLabel>
|
|
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
|
|
<SidebarMenu className="gap-px">
|
|
{NAV_ITEMS.filter((i) => i.group === group).map((item) => (
|
|
<NavEntry
|
|
key={item.id}
|
|
item={item}
|
|
pathname={pathname}
|
|
pinned={pins.includes(item.id)}
|
|
onTogglePin={() => togglePin(item.id)}
|
|
/>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</div>
|
|
))}
|
|
</SidebarContent>
|
|
<SidebarFooter>
|
|
<PreviewNavUser />
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
)
|
|
}
|
|
|
|
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
|
|
variant="outline"
|
|
type="button"
|
|
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-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
|
/>
|
|
}
|
|
>
|
|
<ActivityIcon className="text-warning size-4" aria-hidden />
|
|
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
|
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]: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 min-[760px]:inline-flex min-[1240px]:hidden">
|
|
{bad.length}
|
|
</span>
|
|
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="flex w-[24rem] 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="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 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 min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
|
|
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
|
|
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
|
|
<div className="bg-muted-foreground/25 mt-auto 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>
|
|
)
|
|
}
|
|
|
|
function FreshnessPopover() {
|
|
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
|
const key = pathname.split('/')[2] ?? 'apps'
|
|
const fresh = FRESH_BY_PAGE[key] ?? FRESH_BY_PAGE.apps!
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
|
|
}
|
|
>
|
|
<InfoIcon aria-hidden />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
|
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
|
|
<p className="text-muted-foreground text-sm">{fresh.text}</p>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
export function PreviewHeader() {
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
const { setPaletteOpen } = usePreviewStore()
|
|
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
|
|
|
return (
|
|
<header className="bg-background/80 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">
|
|
<BreadcrumbList className="flex-nowrap">
|
|
<BreadcrumbItem className="min-w-0">
|
|
<BreadcrumbPage className="truncate">{current?.label ?? 'Макет'}</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
<FreshnessPopover />
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
aria-label="Командная палитра (⌘K)"
|
|
className="md:hidden"
|
|
onClick={() => setPaletteOpen(true)}
|
|
>
|
|
<SearchIcon aria-hidden />
|
|
</Button>
|
|
<MonitorPopover />
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
type PaletteEntry = {
|
|
id: string
|
|
group: string
|
|
label: string
|
|
hint: string
|
|
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
|
dot?: 'ok' | 'er' | 'mu'
|
|
run: () => void
|
|
}
|
|
|
|
const PALETTE_USERS = [
|
|
{ name: 'admin@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
|
|
{ name: 'ops@shnt.top', role: 'Пользователь · 4 приложения', dot: 'er' },
|
|
{ name: 'denis@shnt.top', role: 'Админ · 7 приложений', dot: 'ok' },
|
|
{ name: 'dns-bot@shnt.top', role: 'Пользователь · Technitium DNS', dot: 'ok' },
|
|
{ name: 'former@shnt.top', role: 'Отключён', dot: 'mu' },
|
|
] as const
|
|
|
|
const PALETTE_APPS = [
|
|
{ name: 'Cloudflare Domain Manager', role: 'SSO · JWT' },
|
|
{ name: 'VPS Tracker', role: 'SSO · JWT' },
|
|
{ name: 'EvoBGP', role: 'SSO · JWT' },
|
|
{ name: 'EvoFirewall', role: 'SSO · JWT' },
|
|
{ name: 'Technitium DNS', role: 'OIDC' },
|
|
]
|
|
|
|
const PALETTE_OIDC = [
|
|
{ name: 'Technitium DNS', role: 'client_id dns-shnt · включён' },
|
|
{ name: 'Grafana (тест)', role: 'client_id grafana-test · выключен' },
|
|
]
|
|
|
|
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
|
|
|
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
|
export function CommandPalette() {
|
|
const { paletteOpen, setPaletteOpen } = usePreviewStore()
|
|
const navigate = useNavigate()
|
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
|
const [query, setQuery] = useState('')
|
|
const [active, setActive] = useState(0)
|
|
|
|
const shown = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
|
const go = (to: string) => () => void navigate({ to })
|
|
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
|
|
const list: PaletteEntry[] = [
|
|
...[
|
|
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, run: go('/design-preview/users') },
|
|
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, run: go('/design-preview/oidc') },
|
|
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, run: go('/design-preview/account') },
|
|
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: PlayIcon, run: go('/design-preview/logins') },
|
|
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
|
]
|
|
.filter((e) => match(e.label))
|
|
.map((e) => ({ ...e, group: 'Действия' })),
|
|
...NAV_ITEMS.filter((i) => match(i.label, i.group))
|
|
.slice(0, 7)
|
|
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
|
|
...PALETTE_USERS.filter((u) => match(u.name, u.role))
|
|
.slice(0, 5)
|
|
.map((u) => ({ id: `user-${u.name}`, group: 'Пользователи', label: u.name, hint: u.role, dot: u.dot, run: go('/design-preview/users') })),
|
|
...PALETTE_APPS.filter((a) => match(a.name))
|
|
.slice(0, 4)
|
|
.map((a) => ({ id: `app-${a.name}`, group: 'Приложения', label: a.name, hint: a.role, dot: 'mu' as const, run: go('/design-preview/apps') })),
|
|
...PALETTE_OIDC.filter((c) => match(c.name))
|
|
.slice(0, 4)
|
|
.map((c) => ({ id: `oidc-${c.name}`, group: 'OIDC-клиенты', label: c.name, hint: c.role, dot: 'mu' as const, run: go('/design-preview/oidc') })),
|
|
]
|
|
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
|
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
|
return [
|
|
{
|
|
id: 'ai',
|
|
group: 'Ассистент · запрос на естественном языке',
|
|
label: `Понял так: «${query.trim()}»`,
|
|
hint: 'Найдено 6 событий у 2 пользователей. Это черновик — результат нужно проверить.',
|
|
run: () => void navigate({ to: '/design-preview/logins' }),
|
|
} as PaletteEntry,
|
|
...list,
|
|
]
|
|
}
|
|
return list
|
|
}, [query, navigate, theme, resolvedTheme, setTheme])
|
|
|
|
const groups = useMemo(() => {
|
|
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
|
|
shown.forEach((e, index) => {
|
|
const list = map.get(e.group) ?? []
|
|
list.push({ ...e, index })
|
|
map.set(e.group, list)
|
|
})
|
|
return [...map.entries()]
|
|
}, [shown])
|
|
|
|
function close() {
|
|
setPaletteOpen(false)
|
|
setQuery('')
|
|
setActive(0)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
|
|
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
|
|
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
|
|
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
|
|
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
|
|
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
aria-label="Поиск и команды"
|
|
placeholder="Раздел, пользователь, приложение или действие…"
|
|
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setActive(0)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setActive((a) => Math.min(a + 1, shown.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setActive((a) => Math.max(a - 1, 0))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
shown[active]?.run()
|
|
close()
|
|
}
|
|
}}
|
|
/>
|
|
<Kbd>Esc</Kbd>
|
|
</div>
|
|
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
|
{groups.length === 0 ? (
|
|
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
|
) : (
|
|
groups.map(([group, list]) => (
|
|
<div key={group} className="flex flex-col">
|
|
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
|
|
{list.map((e) => {
|
|
const Icon = e.icon
|
|
return (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={e.index === active}
|
|
onMouseEnter={() => setActive(e.index)}
|
|
onClick={() => {
|
|
e.run()
|
|
close()
|
|
}}
|
|
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
|
|
>
|
|
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
|
|
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
|
|
<span className="min-w-0 truncate">{e.label}</span>
|
|
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
|
|
<span>↑↓ выбор</span>
|
|
<span>↵ открыть</span>
|
|
<span>Esc закрыть</span>
|
|
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|