fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
700ad508d8
commit
c8d69bbdfa
4 files changed
+129
-78
No files matched your search
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user