fix(design-preview): палитра shnt Ops, окно прав и палитра ⌘K как на досках
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
700ad508d8
commit
c8d69bbdfa
4 files changed
+117
-66
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 { 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user