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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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
+117 -66

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 { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared'
import { import {
Select, Select,
@@ -78,18 +79,13 @@ export function AccessEditor({
if (!catalog) return null if (!catalog) return null
return ( return (
<TabsContent key={appId} value={appId} className="min-h-0 overflow-y-auto"> <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) => { {catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions) const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions] const options: Level[] = ['none', ...section.actions]
return ( return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3.5 py-3"> <Field key={section.id}>
<div className="flex min-w-0 flex-1 basis-44 flex-col"> <FieldLabel>{section.title}</FieldLabel>
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">
{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}
</span>
</div>
<Select <Select
value={level} value={level}
onValueChange={(v) => onValueChange={(v) =>
@@ -97,10 +93,10 @@ export function AccessEditor({
} }
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent>
<SelectGroup> <SelectGroup>
{options.map((o) => ( {options.map((o) => (
<SelectItem key={o} value={o}> <SelectItem key={o} value={o}>
@@ -110,10 +106,11 @@ export function AccessEditor({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</li> <FieldDescription>{level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
) )
})} })}
</ul> </div>
</TabsContent> </TabsContent>
) )
})} })}
+3 -3
View File
@@ -145,11 +145,11 @@ export function PreviewSheet({
side="right" side="right"
showCloseButton={false} showCloseButton={false}
className={cn( 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', 'data-[side=right]:sm:max-w-none',
size === 'lg' size === 'lg'
? 'data-[side=right]:w-[min(56rem,calc(100vw-2rem))]' ? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(26rem,calc(100vw-2rem))]', : 'data-[side=right]:w-[min(30rem,100vw)]',
)} )}
> >
<SheetHeader className="shrink-0 gap-0 p-0"> <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): только пока смонтирован макет. */ /* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] { html[data-preview-density] {
--background: oklch(0.973 0.004 247.858); --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: 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: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.006 264); --popover-foreground: oklch(0.205 0.01 264);
--secondary: oklch(0.958 0.005 247.858); --secondary: oklch(0.955 0.005 264);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858); --muted: oklch(0.95 0.006 264);
--muted-foreground: oklch(0.52 0.01 264); --muted-foreground: oklch(0.44 0.02 264);
--accent: oklch(0.942 0.016 264.376); --accent: oklch(0.93 0.01 264);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264); --border: oklch(0.88 0.008 264);
--input: oklch(0.904 0.006 264); --input: oklch(0.86 0.008 264);
--ring: oklch(0.488 0.243 264.376); --ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264); --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: oklch(0.93 0.01 264);
--sidebar-accent-foreground: oklch(0.205 0 0); --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); --sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27); --destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163); --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: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--invert: oklch(0.37 0.013 285.805);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48); --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'] { html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0); --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); --foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150); --success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 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; font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent); 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;
}
+83 -40
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 { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { import {
ActivityIcon, ActivityIcon,
@@ -18,7 +18,9 @@ import {
ServerIcon, ServerIcon,
StarIcon, StarIcon,
ShieldIcon, ShieldIcon,
UserPlusIcon, MoonIcon,
PlayIcon,
PlusIcon,
} from 'lucide-react' } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -67,7 +69,7 @@ import {
} 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 { 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' import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = { const APP_ICON: Record<AppId, typeof CloudIcon> = {
@@ -284,7 +286,7 @@ export function PreviewSidebar() {
) : null} ) : null}
{NAV_GROUPS.map((group, gi) => ( {NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col"> <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"> <SidebarGroup className="py-0">
<SidebarGroupLabel <SidebarGroupLabel
render={ render={
@@ -494,46 +496,85 @@ type PaletteEntry = {
group: string group: string
label: string label: string
hint: string hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void 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() { export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = usePreviewStore() const { paletteOpen, setPaletteOpen } = usePreviewStore()
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [active, setActive] = useState(0) 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 shown = useMemo(() => {
const q = query.trim().toLowerCase() 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»). // Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) { if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [ return [
{ {
id: 'ai', id: 'ai',
group: 'Ассистент · запрос обычным текстом (черновик)', group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`, label: `Понял так: «${query.trim()}»`,
hint: 'Найдено 6 событий у 2 пользователей — открыть журнал входов с фильтром. Результат нужно проверить.', hint: 'Найдено 6 событий у 2 пользователей. Это черновик — результат нужно проверить.',
run: () => void navigate({ to: '/design-preview/logins' }), run: () => void navigate({ to: '/design-preview/logins' }),
} as PaletteEntry, } as PaletteEntry,
...found, ...list,
] ]
} }
return found return list
}, [entries, query, navigate]) }, [query, navigate, theme, resolvedTheme, setTheme])
const groups = useMemo(() => { const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>() const map = new Map<string, Array<PaletteEntry & { index: number }>>()
@@ -553,17 +594,17 @@ export function CommandPalette() {
return ( return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}> <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> <DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription> <DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2 border-b px-3"> <div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input <input
autoFocus autoFocus
value={query} value={query}
aria-label="Поиск и команды" aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…" 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) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
setActive(0) setActive(0)
@@ -584,14 +625,16 @@ export function CommandPalette() {
/> />
<Kbd>Esc</Kbd> <Kbd>Esc</Kbd>
</div> </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 ? ( {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]) => ( groups.map(([group, list]) => (
<div key={group} className="flex flex-col"> <div key={group} className="flex flex-col">
<div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{group}</div> <div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => ( {list.map((e) => {
const Icon = e.icon
return (
<button <button
key={e.id} key={e.id}
type="button" type="button"
@@ -602,24 +645,24 @@ export function CommandPalette() {
e.run() e.run()
close() close()
}} }}
className={cn( 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')}
'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} {Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
<span className="min-w-0 flex-1 truncate">{e.label}</span> {e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="text-muted-foreground truncate text-xs">{e.hint}</span> <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> </button>
))} )
})}
</div> </div>
)) ))
)} )}
</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>↵ открыть</span> <span>↵ открыть</span>
<span>Esc закрыть</span> <span>Esc закрыть</span>
<span className="ml-auto">Подсказка: «неудачные входы ops за сутки»</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>