feat(web): оболочка портала на блоках макета — меню, шапка, монитор, палитра ⌘K
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6898d2255d
commit
148a778ba1
9 files changed
+973
-24
No files matched your search
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
AppWindowIcon,
|
||||||
|
FingerprintIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
LogInIcon,
|
||||||
|
PlusIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
|
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||||
|
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||||
|
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||||
|
import { auditQueryOptions } from '@/queries/audit'
|
||||||
|
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
|
||||||
|
import { oidcClientsQueryOptions } from '@/queries/oidc'
|
||||||
|
import { sessionsQueryOptions } from '@/queries/sessions'
|
||||||
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
|
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
|
||||||
|
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||||
|
|
||||||
|
const SKIP_TO_CONTENT_CLASS =
|
||||||
|
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||||
|
|
||||||
|
const FRESH: Record<string, { badge: string; text: string }> = {
|
||||||
|
'/apps': { badge: 'Портал', text: 'Права из базы портала, а не из токена · SSO открывает приложение с текущей сессией' },
|
||||||
|
'/account': { badge: 'WebAuthn', text: 'Passkeys привязаны к домену портала · пароль остаётся запасным входом' },
|
||||||
|
'/admin/logins': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=logins · хранение по настройке ротации журнала' },
|
||||||
|
'/admin/audit': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=changes · события портала и приложений через ingest' },
|
||||||
|
'/admin/oidc': { badge: 'OIDC', text: 'Discovery и JWKS публикует портал · секрет клиента показывается один раз' },
|
||||||
|
'/admin/apps': { badge: 'Портал', text: 'portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
|
||||||
|
'/admin': { badge: 'Портал', text: 'GET /api/v1/admin/users · права на приложения и разделы' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshFor(pathname: string) {
|
||||||
|
const key = Object.keys(FRESH)
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
|
||||||
|
return FRESH[key ?? '/apps']!
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsOf(name: string, email: string) {
|
||||||
|
const src = (name || email).trim()
|
||||||
|
const parts = src.split(/[\s@._-]+/).filter(Boolean)
|
||||||
|
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Меню портала: «Портал» и «Админ» (для администраторов). Точки проблем приходят из данных. */
|
||||||
|
function useChromeData(): ChromeConfig {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const meQ = useQuery(meQueryOptions)
|
||||||
|
const isAdmin = Boolean(meQ.data?.is_admin)
|
||||||
|
const catalogQ = useQuery(catalogQueryOptions)
|
||||||
|
const usersQ = useQuery({ ...usersQueryOptions, enabled: isAdmin, retry: false })
|
||||||
|
const oidcQ = useQuery({ ...oidcClientsQueryOptions, enabled: isAdmin, retry: false })
|
||||||
|
const sessionsQ = useQuery({ ...sessionsQueryOptions(), enabled: isAdmin, retry: false })
|
||||||
|
const loginsQ = useQuery({ ...auditQueryOptions({ kind: 'logins', limit: 100 }), enabled: isAdmin, retry: false })
|
||||||
|
|
||||||
|
return useMemo<ChromeConfig>(() => {
|
||||||
|
const users = usersQ.data ?? []
|
||||||
|
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0)
|
||||||
|
const clients = oidcQ.data ?? []
|
||||||
|
const hourAgo = loginsQ.dataUpdatedAt - 60 * 60 * 1000
|
||||||
|
const failed = (loginsQ.data ?? []).filter((e) => e.action === 'auth.login_failed' && new Date(e.created_at).getTime() >= hourAgo)
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: 'apps', label: 'Приложения', to: '/apps', icon: LayoutGridIcon, group: 'Портал' },
|
||||||
|
{ id: 'account', label: 'Безопасность', to: '/account', icon: FingerprintIcon, group: 'Портал' },
|
||||||
|
]
|
||||||
|
if (isAdmin) {
|
||||||
|
navItems.push(
|
||||||
|
{ id: 'users', label: 'Пользователи', to: '/admin', icon: UsersIcon, group: 'Админ' },
|
||||||
|
{
|
||||||
|
id: 'logins',
|
||||||
|
label: 'Входы',
|
||||||
|
to: '/admin/logins',
|
||||||
|
icon: LogInIcon,
|
||||||
|
group: 'Админ',
|
||||||
|
problem: failed.length >= 5 ? { tone: 'er', label: `${failed.length} неудачных входов за час` } : undefined,
|
||||||
|
},
|
||||||
|
{ id: 'audit', label: 'Изменения', to: '/admin/audit', icon: HistoryIcon, group: 'Админ' },
|
||||||
|
{ id: 'oidc', label: 'OIDC-клиенты', to: '/admin/oidc', icon: KeyRoundIcon, group: 'Админ' },
|
||||||
|
{ id: 'app-links', label: 'Ссылки приложений', to: '/admin/apps', icon: AppWindowIcon, group: 'Админ' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const monitorItems: MonitorItem[] = []
|
||||||
|
if (failed.length >= 5) {
|
||||||
|
monitorItems.push({
|
||||||
|
id: 'bruteforce',
|
||||||
|
tone: 'er',
|
||||||
|
title: `Неудачных входов за час: ${failed.length}`,
|
||||||
|
text: 'Проверьте журнал входов и при необходимости отключите учётную запись',
|
||||||
|
age: 'час',
|
||||||
|
to: '/admin/logins',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (noPasskey.length > 0) {
|
||||||
|
monitorItems.push({
|
||||||
|
id: 'nopasskey',
|
||||||
|
tone: 'wn',
|
||||||
|
title: `${noPasskey.length} без passkey`,
|
||||||
|
text: noPasskey.slice(0, 3).map((u) => u.email.split('@')[0]).join(', ') + ' · вход только по паролю',
|
||||||
|
age: 'сейчас',
|
||||||
|
to: '/admin',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
monitorItems.push({ id: 'api', tone: 'ok', title: 'API портала', text: 'GET /ready', age: 'сейчас', to: '/apps' })
|
||||||
|
|
||||||
|
const enabledClients = clients.filter((c) => c.enabled).length
|
||||||
|
const metrics: MonitorMetric[] = isAdmin
|
||||||
|
? [
|
||||||
|
{ l: 'Пользователи без passkey', v: `${noPasskey.length} из ${users.length}`, p: users.length ? Math.round((noPasskey.length / users.length) * 100) : 0, tone: noPasskey.length ? 'warning' : 'success' },
|
||||||
|
{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' },
|
||||||
|
{ l: 'OIDC-клиенты', v: `${enabledClients} из ${clients.length}`, p: clients.length ? Math.round((enabledClients / clients.length) * 100) : 100, tone: enabledClients === clients.length ? 'success' : 'warning' },
|
||||||
|
{ l: 'Сессии', v: String(sessionsQ.data?.length ?? 0), p: Math.min(100, (sessionsQ.data?.length ?? 0) * 10), tone: 'success' },
|
||||||
|
]
|
||||||
|
: [{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }]
|
||||||
|
|
||||||
|
const objects: PaletteObject[] = [
|
||||||
|
...(isAdmin
|
||||||
|
? users.map<PaletteObject>((u) => ({
|
||||||
|
id: `user-${u.id}`,
|
||||||
|
group: 'Пользователи',
|
||||||
|
label: u.email,
|
||||||
|
hint: u.disabled ? 'Отключён' : u.is_admin ? 'Админ' : 'Пользователь',
|
||||||
|
dot: u.disabled ? 'mu' : u.passkey_count === 0 ? 'er' : 'ok',
|
||||||
|
to: `/admin/users/${u.id}`,
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
...(catalogQ.data?.apps ?? []).map<PaletteObject>((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })),
|
||||||
|
...(isAdmin
|
||||||
|
? clients.map<PaletteObject>((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: `client_id ${c.client_id}`, dot: 'mu', to: '/admin/oidc' }))
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const me = meQ.data
|
||||||
|
return {
|
||||||
|
navItems,
|
||||||
|
navGroups: isAdmin ? ['Портал', 'Админ'] : ['Портал'],
|
||||||
|
brand: <AppSwitcher />,
|
||||||
|
user: { name: me?.name ?? '…', email: me?.email ?? '', initials: initialsOf(me?.name ?? '', me?.email ?? '') },
|
||||||
|
securityTo: '/account',
|
||||||
|
onLogout: () => void navigate({ to: '/logout' }),
|
||||||
|
fresh: freshFor,
|
||||||
|
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: isAdmin ? '/admin/logins' : '/apps', footerLabel: isAdmin ? 'Журнал входов' : 'Приложения' },
|
||||||
|
palette: {
|
||||||
|
actions: isAdmin
|
||||||
|
? [
|
||||||
|
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, to: '/admin' },
|
||||||
|
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, to: '/admin/oidc' },
|
||||||
|
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' },
|
||||||
|
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: LogInIcon, to: '/admin/logins' },
|
||||||
|
]
|
||||||
|
: [{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }],
|
||||||
|
objects,
|
||||||
|
assistantTo: isAdmin ? '/admin/logins' : '/apps',
|
||||||
|
example: 'Подсказка: «неудачные входы ops за сутки»',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [navigate, meQ.data, isAdmin, catalogQ.data, usersQ.data, oidcQ.data, sessionsQ.data, loginsQ.data, loginsQ.dataUpdatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оболочка портала: боковое меню, шапка, палитра ⌘K. Макет — `design-preview` на тех же блоках. */
|
||||||
|
export function PortalShell({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
|
||||||
|
const mq = window.matchMedia('(min-width: 1100px)')
|
||||||
|
const sync = () => setOpen(mq.matches)
|
||||||
|
sync()
|
||||||
|
mq.addEventListener('change', sync)
|
||||||
|
setMounted(true)
|
||||||
|
return () => mq.removeEventListener('change', sync)
|
||||||
|
}, [])
|
||||||
|
const config = useChromeData()
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalUiProvider>
|
||||||
|
<ChromeConfigProvider value={config}>
|
||||||
|
<TooltipProvider delay={0}>
|
||||||
|
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
|
<PortalSidebar />
|
||||||
|
<SidebarInset id="main-content" className="min-w-0">
|
||||||
|
<PortalHeader />
|
||||||
|
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||||
|
</SidebarInset>
|
||||||
|
<CommandPalette />
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ChromeConfigProvider>
|
||||||
|
</PortalUiProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
|
||||||
|
|
||||||
|
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
|
||||||
|
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
/* без сохранения настроек */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
|
||||||
|
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
|
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
|
||||||
|
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
|
||||||
|
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
|
||||||
|
|
||||||
|
const setDensity = useCallback(
|
||||||
|
(v: Density) => {
|
||||||
|
setDensityState(v)
|
||||||
|
writeStored(`${storageKey}:density`, v)
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
const setContrast = useCallback(
|
||||||
|
(v: Contrast) => {
|
||||||
|
setContrastState(v)
|
||||||
|
writeStored(`${storageKey}:contrast`, v)
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
const togglePin = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setPins((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
writeStored(`${storageKey}:pins`, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.dataset.density = density
|
||||||
|
root.dataset.contrast = contrast
|
||||||
|
return () => {
|
||||||
|
delete root.dataset.density
|
||||||
|
delete root.dataset.contrast
|
||||||
|
}
|
||||||
|
}, [density, contrast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setPaletteOpen((v) => !v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<UiStore>(
|
||||||
|
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
|
||||||
|
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
|
||||||
|
)
|
||||||
|
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
icon: LucideIcon
|
||||||
|
group: string
|
||||||
|
/** Точка проблемы в углу иконки. */
|
||||||
|
problem?: { tone: 'er' | 'wn'; label: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonitorItem = {
|
||||||
|
id: string
|
||||||
|
tone: 'er' | 'wn' | 'ok'
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
age: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
|
||||||
|
|
||||||
|
export type PaletteAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** Горячая буква справа. */
|
||||||
|
hint: string
|
||||||
|
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaletteObject = {
|
||||||
|
id: string
|
||||||
|
group: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
dot?: 'ok' | 'er' | 'mu'
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
|
||||||
|
export type ChromeConfig = {
|
||||||
|
navItems: NavItem[]
|
||||||
|
navGroups: string[]
|
||||||
|
/** Верх бокового меню: бренд и переключатель приложений. */
|
||||||
|
brand: ReactNode
|
||||||
|
user: { name: string; email: string; initials: string }
|
||||||
|
securityTo: string
|
||||||
|
onLogout: () => void
|
||||||
|
fresh: (pathname: string) => { badge: string; text: string }
|
||||||
|
crumbFor?: (pathname: string, label: string | undefined) => string
|
||||||
|
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
|
||||||
|
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
|
||||||
|
|
||||||
|
export function useChromeConfig(): ChromeConfig {
|
||||||
|
const ctx = useContext(ChromeConfigContext)
|
||||||
|
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Density = 'comfortable' | 'compact'
|
||||||
|
export type Contrast = 'standard' | 'high'
|
||||||
|
|
||||||
|
export type UiStore = {
|
||||||
|
paletteOpen: boolean
|
||||||
|
setPaletteOpen: (open: boolean) => void
|
||||||
|
pins: string[]
|
||||||
|
togglePin: (id: string) => void
|
||||||
|
density: Density
|
||||||
|
setDensity: (v: Density) => void
|
||||||
|
contrast: Contrast
|
||||||
|
setContrast: (v: Contrast) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UiStoreContext = createContext<UiStore | null>(null)
|
||||||
|
|
||||||
|
export function useUiStore(): UiStore {
|
||||||
|
const ctx = useContext(UiStoreContext)
|
||||||
|
if (!ctx) throw new Error('PortalUiProvider отсутствует')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
FingerprintIcon,
|
||||||
|
InfoIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
SearchIcon,
|
||||||
|
StarIcon,
|
||||||
|
MoonIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useTheme,
|
||||||
|
} from 'next-themes'
|
||||||
|
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 {
|
||||||
|
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 './bits'
|
||||||
|
import {
|
||||||
|
useChromeConfig,
|
||||||
|
useUiStore,
|
||||||
|
type NavItem,
|
||||||
|
} from './chrome-store'
|
||||||
|
|
||||||
|
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
|
||||||
|
|
||||||
|
function PortalNavUser() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||||
|
const { density, setDensity, contrast, setContrast } = useUiStore()
|
||||||
|
const { user, securityTo, onLogout } = useChromeConfig()
|
||||||
|
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={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
|
||||||
|
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">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||||
|
<span className="truncate text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{user.email}</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">
|
||||||
|
{user.initials}
|
||||||
|
</div>
|
||||||
|
<div className="grid leading-tight">
|
||||||
|
<span className="text-sm font-semibold">{user.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate({ to: securityTo })}
|
||||||
|
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={onLogout}
|
||||||
|
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 PortalSidebar() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { pins, togglePin } = useUiStore()
|
||||||
|
const { navItems, navGroups, brand } = useChromeConfig()
|
||||||
|
const [closed, setClosed] = useState<Record<string, boolean>>({})
|
||||||
|
const pinned = navItems.filter((i) => pins.includes(i.id))
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="pb-1.5">
|
||||||
|
{brand}
|
||||||
|
</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}
|
||||||
|
{navGroups.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">
|
||||||
|
{navItems.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>
|
||||||
|
<PortalNavUser />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
|
||||||
|
function MonitorPopover() {
|
||||||
|
const { monitor } = useChromeConfig()
|
||||||
|
const MONITOR_ITEMS = monitor.items
|
||||||
|
const MONITOR_METRICS = monitor.metrics
|
||||||
|
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">{monitor.updated}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</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={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
|
||||||
|
{monitor.footerLabel}
|
||||||
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreshnessPopover() {
|
||||||
|
const pathname = useRouterState({ select: (st) => st.location.pathname })
|
||||||
|
const { fresh: freshFor } = useChromeConfig()
|
||||||
|
const fresh = freshFor(pathname)
|
||||||
|
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 PortalHeader() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const { setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, crumbFor } = useChromeConfig()
|
||||||
|
const current = navItems.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">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</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 DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
|
||||||
|
|
||||||
|
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { paletteOpen, setPaletteOpen } = useUiStore()
|
||||||
|
const { navItems, palette } = useChromeConfig()
|
||||||
|
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[] = [
|
||||||
|
...[
|
||||||
|
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
|
||||||
|
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
|
||||||
|
]
|
||||||
|
.filter((e) => match(e.label))
|
||||||
|
.map((e) => ({ ...e, group: 'Действия' })),
|
||||||
|
...navItems.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) })),
|
||||||
|
...Object.values(
|
||||||
|
palette.objects
|
||||||
|
.filter((o) => match(o.label, o.hint))
|
||||||
|
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||||
|
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||||
|
]
|
||||||
|
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||||
|
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
group: 'Ассистент · запрос на естественном языке',
|
||||||
|
label: `Понял так: «${query.trim()}»`,
|
||||||
|
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||||
|
run: () => void navigate({ to: palette.assistantTo }),
|
||||||
|
} as PaletteEntry,
|
||||||
|
...list,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||||
|
|
||||||
|
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">{palette.example}</span>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.dataset.previewDensity = density
|
root.dataset.density = density
|
||||||
root.dataset.previewContrast = contrast
|
root.dataset.contrast = contrast
|
||||||
return () => {
|
return () => {
|
||||||
delete root.dataset.previewDensity
|
delete root.dataset.density
|
||||||
delete root.dataset.previewContrast
|
delete root.dataset.contrast
|
||||||
}
|
}
|
||||||
}, [density, contrast])
|
}, [density, contrast])
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar
|
|||||||
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
import { TooltipProvider } from '@authportal/ui/components/tooltip'
|
||||||
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
|
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
|
||||||
import { UnsavedBar } from './unsaved-bar'
|
import { UnsavedBar } from './unsaved-bar'
|
||||||
import './design-preview.css'
|
|
||||||
|
|
||||||
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
|
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
|
||||||
export function PreviewShell({ children }: { children: ReactNode }) {
|
export function PreviewShell({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes'
|
|||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import '@authportal/ui/globals.css'
|
import '@authportal/ui/globals.css'
|
||||||
|
import './portal-theme.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
@import '@fontsource-variable/geist-mono';
|
@import '@fontsource-variable/geist-mono';
|
||||||
|
|
||||||
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
|
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
|
||||||
html[data-preview-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||||
padding-block: 0.25rem;
|
padding-block: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
|
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
|
||||||
html[data-preview-density] {
|
html:not(.dark) {
|
||||||
--background: oklch(0.973 0.004 247.858);
|
--background: oklch(0.973 0.004 247.858);
|
||||||
--foreground: oklch(0.205 0.01 264);
|
--foreground: oklch(0.205 0.01 264);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@@ -43,7 +43,7 @@ html[data-preview-density] {
|
|||||||
--info-foreground: oklch(0.42 0.2 293);
|
--info-foreground: oklch(0.42 0.2 293);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-preview-density].dark {
|
html.dark {
|
||||||
--background: oklch(0.133 0.011 264.376);
|
--background: oklch(0.133 0.011 264.376);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.191 0.011 264.376);
|
--card: oklch(0.191 0.011 264.376);
|
||||||
@@ -75,14 +75,14 @@ html[data-preview-density].dark {
|
|||||||
--info-foreground: oklch(0.541 0.281 293.009);
|
--info-foreground: oklch(0.541 0.281 293.009);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-preview-density][data-preview-contrast='high'] {
|
html[data-contrast='high'] {
|
||||||
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
|
--border: color-mix(in oklch, var(--foreground) 55%, transparent);
|
||||||
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
|
--input: color-mix(in oklch, var(--foreground) 60%, transparent);
|
||||||
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
|
--muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent);
|
||||||
--ring: oklch(0.35 0.2 264);
|
--ring: oklch(0.35 0.2 264);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-preview-density].dark[data-preview-contrast='high'] {
|
html.dark[data-contrast='high'] {
|
||||||
--background: oklch(0 0 0);
|
--background: oklch(0 0 0);
|
||||||
--primary: oklch(1 0 0);
|
--primary: oklch(1 0 0);
|
||||||
--primary-foreground: oklch(0 0 0);
|
--primary-foreground: oklch(0 0 0);
|
||||||
@@ -106,40 +106,40 @@ html[data-preview-density].dark[data-preview-contrast='high'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
||||||
html[data-preview-density] {
|
html {
|
||||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
|
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-preview-density],
|
html,
|
||||||
html[data-preview-density] body {
|
html body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||||
html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible,
|
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||||
html[data-preview-density] #main-content :focus-visible {
|
html #main-content :focus-visible {
|
||||||
outline: 2px solid var(--ring);
|
outline: 2px solid var(--ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html[data-preview-density] #main-content *,
|
html #main-content *,
|
||||||
html[data-preview-density] [data-slot='sidebar-wrapper'] * {
|
html [data-slot='sidebar-wrapper'] * {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
|
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
|
||||||
html[data-preview-density] [data-slot='data-grid-table'] thead th,
|
html [data-slot='data-grid-table'] thead th,
|
||||||
html[data-preview-density] [data-slot='data-grid-table'] thead th :is(span, button, div) {
|
html [data-slot='data-grid-table'] thead th :is(span, button, div) {
|
||||||
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']) {
|
html :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) {
|
||||||
background: oklch(0 0 0 / 55%);
|
background: oklch(0 0 0 / 55%);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { AppShell } from '@/components/layout/app-shell'
|
import { PortalShell } from '@/components/layout/portal-shell'
|
||||||
import {
|
import {
|
||||||
clearToken,
|
clearToken,
|
||||||
getToken,
|
getToken,
|
||||||
@@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
|
|
||||||
function AuthLayout() {
|
function AuthLayout() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<PortalShell>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppShell>
|
</PortalShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user