From 148a778ba1089459f6f8650e9ed1fec29243853d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 13:59:35 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=D1=85=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=E2=80=94=20=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8E,=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0,=20=D0=BC=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D1=80,=20=D0=BF=D0=B0=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=80=D0=B0=20=E2=8C=98K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/layout/portal-shell.tsx | 211 +++++++ .../src/components/portal/chrome-context.tsx | 83 +++ .../web/src/components/portal/chrome-store.ts | 86 +++ apps/web/src/components/portal/chrome.tsx | 569 ++++++++++++++++++ .../src/design-preview/preview-provider.tsx | 8 +- apps/web/src/design-preview/preview-shell.tsx | 1 - apps/web/src/main.tsx | 1 + .../design-preview.css => portal-theme.css} | 32 +- apps/web/src/routes/_auth.tsx | 6 +- 9 files changed, 973 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/layout/portal-shell.tsx create mode 100644 apps/web/src/components/portal/chrome-context.tsx create mode 100644 apps/web/src/components/portal/chrome-store.ts create mode 100644 apps/web/src/components/portal/chrome.tsx rename apps/web/src/{design-preview/design-preview.css => portal-theme.css} (82%) diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx new file mode 100644 index 0000000..a0930d4 --- /dev/null +++ b/apps/web/src/components/layout/portal-shell.tsx @@ -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 = { + '/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(() => { + 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((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((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })), + ...(isAdmin + ? clients.map((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: , + 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 ( +
+ + +
+ ) + } + + return ( + + + + + + К содержимому + + + + +
{children}
+
+ +
+
+
+
+ ) +} diff --git a/apps/web/src/components/portal/chrome-context.tsx b/apps/web/src/components/portal/chrome-context.tsx new file mode 100644 index 0000000..6d8aeb7 --- /dev/null +++ b/apps/web/src/components/portal/chrome-context.tsx @@ -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 {children} +} + +function readStored(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(() => readStored(`${storageKey}:density`, 'comfortable')) + const [contrast, setContrastState] = useState(() => readStored(`${storageKey}:contrast`, 'standard')) + const [pins, setPins] = useState(() => 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( + () => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }), + [paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast], + ) + return {children} +} diff --git a/apps/web/src/components/portal/chrome-store.ts b/apps/web/src/components/portal/chrome-store.ts new file mode 100644 index 0000000..7763f0d --- /dev/null +++ b/apps/web/src/components/portal/chrome-store.ts @@ -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(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(null) + +export function useUiStore(): UiStore { + const ctx = useContext(UiStoreContext) + if (!ctx) throw new Error('PortalUiProvider отсутствует') + return ctx +} diff --git a/apps/web/src/components/portal/chrome.tsx b/apps/web/src/components/portal/chrome.tsx new file mode 100644 index 0000000..d04331f --- /dev/null +++ b/apps/web/src/components/portal/chrome.tsx @@ -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 ( + + + + + } + > +
+ {user.initials} +
+
+ {user.name} + {user.email} +
+ +
+ +
+
+ {user.initials} +
+
+ {user.name} + {user.email} +
+
+ + +
+ Тема + setTheme(i === 1 ? 'light' : 'dark')} + /> + Плотность + setDensity(i === 1 ? 'compact' : 'comfortable')} + /> + Контраст + setContrast(i === 1 ? 'high' : 'standard')} + /> +
+ + +
+
+
+
+ ) +} + +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 ( + + } + > + + {item.label} + {item.problem ? ( + + ) : null} + + + + + + ) +} + +export function PortalSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { pins, togglePin } = useUiStore() + const { navItems, navGroups, brand } = useChromeConfig() + const [closed, setClosed] = useState>({}) + const pinned = navItems.filter((i) => pins.includes(i.id)) + return ( + + + {brand} + + + {pinned.length > 0 ? ( + + Закреплённые + + + {pinned.map((item) => ( + togglePin(item.id)} /> + ))} + + + + ) : null} + {navGroups.map((group, gi) => ( +
+ {gi > 0 ? : null} + + setClosed((c) => ({ ...c, [group]: !c[group] }))} + /> + } + className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase" + > + {group} + + + + + {navItems.filter((i) => i.group === group).map((item) => ( + togglePin(item.id)} + /> + ))} + + + +
+ ))} +
+ + + +
+ ) +} + +/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */ +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 ( + + + } + > + + Внимание + + + {errors} + + {warnings} + + + {bad.length} + + + + +
+
+ Состояние системы + {monitor.updated} +
+ 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"} +
+
+ {[ + { n: errors, l: 'сбой', c: 'text-destructive' }, + { n: warnings, l: 'предупреждения', c: 'text-warning' }, + { n: ok, l: 'в норме', c: 'text-success' }, + ].map((x) => ( + + {x.n} + {x.l} + + ))} +
+
    + {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => ( +
  • + + + + {m.title} + {m.text} + + {m.age} + +
  • + ))} +
+
+ {MONITOR_METRICS.map((m) => ( +
+ {m.l} + {m.v} +
+ +
+
+ ))} +
+ + {monitor.footerLabel} + + +
+
+ ) +} + +function FreshnessPopover() { + const pathname = useRouterState({ select: (st) => st.location.pathname }) + const { fresh: freshFor } = useChromeConfig() + const fresh = freshFor(pathname) + return ( + + + } + > + + + + {fresh.badge} +

{fresh.text}

+
+
+ ) +} + +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 ( +
+ + + + + + {crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')} + + + +
+ + + +
+
+ ) +} + +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>((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>() + 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 ( + (o ? setPaletteOpen(true) : close())}> + + Командная палитра + Разделы, пользователи, приложения и действия +
+ + { + 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() + } + }} + /> + Esc +
+
+ {groups.length === 0 ? ( +

Ничего не найдено

+ ) : ( + groups.map(([group, list]) => ( +
+
{group}
+ {list.map((e) => { + const Icon = e.icon + return ( + + ) + })} +
+ )) + )} +
+
+ ↑↓ выбор + ↵ открыть + Esc закрыть + {palette.example} +
+
+
+ ) +} diff --git a/apps/web/src/design-preview/preview-provider.tsx b/apps/web/src/design-preview/preview-provider.tsx index 5ff02f3..11fea82 100644 --- a/apps/web/src/design-preview/preview-provider.tsx +++ b/apps/web/src/design-preview/preview-provider.tsx @@ -48,11 +48,11 @@ export function PreviewProvider({ children }: { children: ReactNode }) { useEffect(() => { const root = document.documentElement - root.dataset.previewDensity = density - root.dataset.previewContrast = contrast + root.dataset.density = density + root.dataset.contrast = contrast return () => { - delete root.dataset.previewDensity - delete root.dataset.previewContrast + delete root.dataset.density + delete root.dataset.contrast } }, [density, contrast]) diff --git a/apps/web/src/design-preview/preview-shell.tsx b/apps/web/src/design-preview/preview-shell.tsx index 3874e8e..497fadc 100644 --- a/apps/web/src/design-preview/preview-shell.tsx +++ b/apps/web/src/design-preview/preview-shell.tsx @@ -4,7 +4,6 @@ import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar import { TooltipProvider } from '@authportal/ui/components/tooltip' import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome' import { UnsavedBar } from './unsaved-bar' -import './design-preview.css' /** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */ export function PreviewShell({ children }: { children: ReactNode }) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 844c0e4..1002199 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from 'next-themes' import { Toaster } from 'sonner' import { routeTree } from './routeTree.gen' import '@authportal/ui/globals.css' +import './portal-theme.css' const queryClient = new QueryClient({ defaultOptions: { diff --git a/apps/web/src/design-preview/design-preview.css b/apps/web/src/portal-theme.css similarity index 82% rename from apps/web/src/design-preview/design-preview.css rename to apps/web/src/portal-theme.css index 9ec2b9c..b0b62eb 100644 --- a/apps/web/src/design-preview/design-preview.css +++ b/apps/web/src/portal-theme.css @@ -2,16 +2,16 @@ @import '@fontsource-variable/geist-mono'; /* Плотность и контраст макета. Атрибуты ставит 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; } -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; } /* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */ -html[data-preview-density] { +html:not(.dark) { --background: oklch(0.973 0.004 247.858); --foreground: oklch(0.205 0.01 264); --card: oklch(1 0 0); @@ -43,7 +43,7 @@ html[data-preview-density] { --info-foreground: oklch(0.42 0.2 293); } -html[data-preview-density].dark { +html.dark { --background: oklch(0.133 0.011 264.376); --foreground: oklch(0.985 0 0); --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); } -html[data-preview-density][data-preview-contrast='high'] { +html[data-contrast='high'] { --border: color-mix(in oklch, var(--foreground) 55%, transparent); --input: color-mix(in oklch, var(--foreground) 60%, transparent); --muted-foreground: color-mix(in oklch, var(--foreground) 88%, transparent); --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); --primary: oklch(1 0 0); --primary-foreground: oklch(0 0 0); @@ -106,40 +106,40 @@ html[data-preview-density].dark[data-preview-contrast='high'] { } /* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */ -html[data-preview-density] { +html { --font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif; --font-mono: 'Geist Mono Variable', ui-monospace, monospace; } -html[data-preview-density], -html[data-preview-density] body { +html, +html body { font-family: var(--font-sans); } /* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */ -html[data-preview-density] [data-slot='sidebar-wrapper'] :focus-visible, -html[data-preview-density] #main-content :focus-visible { +html [data-slot='sidebar-wrapper'] :focus-visible, +html #main-content :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { - html[data-preview-density] #main-content *, - html[data-preview-density] [data-slot='sidebar-wrapper'] * { + html #main-content *, + html [data-slot='sidebar-wrapper'] * { animation: none !important; transition: none !important; } } /* Шапка таблицы как на досках: 12px, лёгкая подложка. */ -html[data-preview-density] [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, +html [data-slot='data-grid-table'] thead th :is(span, button, div) { 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']) { +html :is([data-slot='sheet-overlay'], [data-slot='dialog-overlay'], [data-slot='alert-dialog-overlay']) { background: oklch(0 0 0 / 55%); backdrop-filter: none; } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 9130532..ea9d011 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,5 +1,5 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' -import { AppShell } from '@/components/layout/app-shell' +import { PortalShell } from '@/components/layout/portal-shell' import { clearToken, getToken, @@ -36,8 +36,8 @@ export const Route = createFileRoute('/_auth')({ function AuthLayout() { return ( - + - + ) }