fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 16:37:40 +07:00
1 parent 700ad508d8
commit c8d69bbdfa
4 files changed
+129 -78

No files matched your search

+9 -12
View File
@@ -1,3 +1,4 @@
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import {
Select,
@@ -78,18 +79,13 @@ export function AccessEditor({
if (!catalog) return null
return (
<TabsContent key={appId} value={appId} className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
<div className="flex flex-col gap-4">
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3.5 py-3">
<div className="flex min-w-0 flex-1 basis-44 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">
{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}
</span>
</div>
<Field key={section.id}>
<FieldLabel>{section.title}</FieldLabel>
<Select
value={level}
onValueChange={(v) =>
@@ -97,10 +93,10 @@ export function AccessEditor({
}
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectTrigger className="w-full" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectContent>
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
@@ -110,10 +106,11 @@ export function AccessEditor({
</SelectGroup>
</SelectContent>
</Select>
</li>
<FieldDescription>{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
)
})}
</ul>
</div>
</TabsContent>
)
})}
+3 -3
View File
@@ -145,11 +145,11 @@ export function PreviewSheet({
side="right"
showCloseButton={false}
className={cn(
'inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none',
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(56rem,calc(100vw-2rem))]'
: 'data-[side=right]:w-[min(26rem,calc(100vw-2rem))]',
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
+22 -11
View File
@@ -13,25 +13,26 @@ html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-chil
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] {
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264);
--foreground: oklch(0.205 0.01 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264);
--card-foreground: oklch(0.205 0.01 264);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.006 264);
--secondary: oklch(0.958 0.005 247.858);
--popover-foreground: oklch(0.205 0.01 264);
--secondary: oklch(0.955 0.005 264);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.52 0.01 264);
--accent: oklch(0.942 0.016 264.376);
--muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.93 0.01 264);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264);
--input: oklch(0.904 0.006 264);
--border: oklch(0.88 0.008 264);
--input: oklch(0.86 0.008 264);
--ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.141 0.006 264);
--sidebar-foreground: oklch(0.205 0.01 264);
--sidebar-accent: oklch(0.93 0.01 264);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.904 0.006 264);
--sidebar-border: oklch(0.88 0.008 264);
--invert: oklch(0.9 0.01 264);
--sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163);
@@ -63,6 +64,7 @@ html[data-preview-density].dark {
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--invert: oklch(0.37 0.013 285.805);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48);
@@ -82,6 +84,9 @@ html[data-preview-density][data-preview-contrast='high'] {
html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--sidebar: oklch(0.1 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
@@ -132,3 +137,9 @@ html[data-preview-density] [data-slot='data-grid-table'] thead th :is(span, butt
font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
/* Затемнение под панелями без размытия — как на досках. */
html[data-preview-density] :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
background: oklch(0 0 0 / 55%);
backdrop-filter: none;
}
+95 -52
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useState, type ComponentType } from 'react'
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import {
ActivityIcon,
@@ -18,7 +18,9 @@ import {
ServerIcon,
StarIcon,
ShieldIcon,
UserPlusIcon,
MoonIcon,
PlayIcon,
PlusIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
@@ -67,7 +69,7 @@ import {
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits'
import { APP_SHORT, FRESH_BY_PAGE, MOCK_OIDC_CLIENTS, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
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> = {
@@ -284,7 +286,7 @@ export function PreviewSidebar() {
) : null}
{NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null}
{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={
@@ -494,46 +496,85 @@ type PaletteEntry = {
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const PALETTE_USERS = [
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · 4 приложения', dot: 'er' },
{ name: '[email protected]', role: 'Админ · 7 приложений', dot: 'ok' },
{ name: '[email protected]', role: 'Пользователь · Technitium DNS', dot: 'ok' },
{ name: '[email protected]', 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 entries = useMemo<PaletteEntry[]>(() => {
const go = (to: string) => () => void navigate({ to })
return [
{ id: 'act-user', group: 'Действия', label: 'Создать пользователя', hint: 'Пользователи', run: go('/design-preview/users') },
{ id: 'act-client', group: 'Действия', label: 'Создать OIDC-клиента', hint: 'OIDC-клиенты', run: go('/design-preview/oidc') },
{ id: 'act-passkey', group: 'Действия', label: 'Добавить passkey', hint: 'Безопасность', run: go('/design-preview/account') },
...NAV_ITEMS.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, run: go(i.to) })),
...MOCK_USERS.map((u) => ({ id: `user-${u.id}`, group: 'Пользователи', label: u.email, hint: u.is_admin ? 'Админ' : 'Пользователь', run: go('/design-preview/users') })),
...APPS.map((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: APP_SHORT[a.id], hint: a.url.replace('https://', ''), run: go('/design-preview/apps') })),
...MOCK_OIDC_CLIENTS.map((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: c.clientId, run: go('/design-preview/oidc') })),
]
}, [navigate])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const found = q ? entries.filter((e) => `${e.label} ${e.hint}`.toLowerCase().includes(q)) : entries
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: 'Ассистент · запрос обычным текстом (черновик)',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Найдено 6 событий у 2 пользователей — открыть журнал входов с фильтром. Результат нужно проверить.',
hint: 'Найдено 6 событий у 2 пользователей. Это черновик — результат нужно проверить.',
run: () => void navigate({ to: '/design-preview/logins' }),
} as PaletteEntry,
...found,
...list,
]
}
return found
}, [entries, query, navigate])
return list
}, [query, navigate, theme, resolvedTheme, setTheme])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +594,17 @@ export function CommandPalette() {
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[20%] w-[min(40rem,calc(100vw-2rem))] max-w-none translate-y-0 gap-0 p-0 sm:max-w-none" showCloseButton={false}>
<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 border-b px-3">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
<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="h-11 min-w-0 flex-1 bg-transparent text-sm outline-none"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
@@ -584,42 +625,44 @@ export function CommandPalette() {
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[50svh] overflow-y-auto p-1.5" role="listbox" aria-label="Результаты">
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">Ничего не найдено</p>
<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-2 py-1.5 text-xs font-medium">{group}</div>
{list.map((e) => (
<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-2 rounded-md px-2 py-2 text-left text-sm outline-none',
e.index === active && 'bg-accent text-accent-foreground',
)}
>
{group === 'Действия' ? <UserPlusIcon className="size-4" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span>
<span className="text-muted-foreground truncate text-xs">{e.hint}</span>
</button>
))}
<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-3 py-2 text-xs">
<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>