feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 12:03:55 +07:00
1 parent f97604c23e
commit 609bea6ad7
2 files changed
+176 -101

No files matched your search

+162 -99
View File
@@ -3,9 +3,8 @@ import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
CheckIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
LayoutGridIcon,
CloudIcon,
FingerprintIcon,
GlobeIcon,
@@ -29,8 +28,10 @@ import { Badge } from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@authportal/ui/components/breadcrumb'
import { Button } from '@authportal/ui/components/button'
import {
@@ -69,7 +70,7 @@ import {
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { Segmented } from './bits'
import { FRESH_BY_PAGE, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
import { APP_SHORT, FRESH_BY_PAGE, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
import { usePreviewStore } from './preview-store'
const APP_ICON: Record<AppId, typeof CloudIcon> = {
@@ -82,90 +83,76 @@ const APP_ICON: Record<AppId, typeof CloudIcon> = {
mm: LayoutDashboardIcon,
}
function PreviewAppSwitcher() {
const { isMobile } = useSidebar()
/** Бренд как в app-shell-12: логотип, название и подпись; переключатель приложений семейства — в шапке. */
function PreviewBrand() {
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" aria-label="Переключатель приложений: Auth Portal" className="aria-expanded:bg-muted" />}
>
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" aria-hidden>
<ShieldCheckIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICON[app.id]
return (
<DropdownMenuItem
key={app.id}
onClick={() => toast(`Открыть ${app.title}`, { description: `${app.url} · SSO handoff (макет)` })}
>
<Icon />
{app.title}
<span className="text-muted-foreground ml-auto font-mono text-[11px]">{app.id}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<SidebarMenuButton size="lg" aria-label="Auth Portal — Приложения" className="gap-2.5" render={<Link to="/design-preview/apps" />}>
<span className="bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center rounded-md" aria-hidden>
<ShieldCheckIcon className="size-4" />
</span>
<span className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">Auth Portal</span>
<span className="text-muted-foreground truncate text-xs">Единый вход · SSO</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)
}
/** Сетка приложений семейства в шапке (app-shell-12: Apps menu). */
function AppsMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" aria-label="Приложения семейства" />}>
<LayoutGridIcon className="size-[18px]" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" sideOffset={8} className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
<DropdownMenuItem disabled className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5">
<KeyRoundIcon className="text-muted-foreground" aria-hidden />
<span className="text-xs font-medium">Auth Portal</span>
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICON[app.id]
return (
<DropdownMenuItem
key={app.id}
onClick={() => toast(`Открыть ${app.title}`, { description: `${app.url} · SSO handoff (макет)` })}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<Icon className="text-muted-foreground" aria-hidden />
<span className="text-xs font-medium">{APP_SHORT[app.id]}</span>
</DropdownMenuItem>
)
})}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PreviewNavUser() {
const { isMobile } = useSidebar()
function PreviewUserMenu() {
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = usePreviewStore()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label="Профиль и настройки интерфейса: Admin, [email protected]"
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">
AD
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">Admin</span>
<span className="text-muted-foreground truncate text-xs">admin@shnt.top</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">
render={<Button variant="ghost" size="icon" aria-label="Профиль и настройки интерфейса: Admin, [email protected]" className="rounded-full" />}
>
<span className="bg-background text-foreground flex size-7 items-center justify-center rounded-full border text-[11px] font-medium">AD</span>
</PopoverTrigger>
<PopoverContent side="bottom" 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">
AD
@@ -217,9 +204,7 @@ function PreviewNavUser() {
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
</Popover>
)
}
@@ -261,6 +246,64 @@ function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; path
)
}
function PreviewSecondaryNav() {
const { setPaletteOpen } = usePreviewStore()
return (
<SidebarGroup className="py-1">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="sm" tooltip="Командная палитра" onClick={() => setPaletteOpen(true)} className="h-8! [&_svg]:size-3.5">
<SearchIcon aria-hidden />
<span>Поиск и команды</span>
<Kbd className="ml-auto group-data-[collapsible=icon]:hidden">⌘K</Kbd>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton size="sm" tooltip="Журнал входов" render={<Link to="/design-preview/logins" />} className="h-8! [&_svg]:size-3.5">
<ActivityIcon aria-hidden />
<span>Журнал входов</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
/** Кнопка на кромке меню (app-shell-12): свернуть или развернуть. */
function SidebarRailToggle() {
const { state, toggleSidebar } = useSidebar()
const expanded = state === 'expanded'
return (
<button
type="button"
aria-label={expanded ? 'Свернуть меню' : 'Развернуть меню'}
onClick={toggleSidebar}
style={{ left: expanded ? 'var(--sidebar-width)' : 'var(--sidebar-width-icon)' }}
className="group/rail fixed top-1/2 z-30 hidden h-12 w-7 -translate-y-1/2 cursor-pointer items-center pl-2 outline-none transition-[left] duration-200 ease-linear md:flex"
>
<span className="flex flex-col items-center">
<span
aria-hidden
className={cn(
'bg-foreground/40 group-hover/rail:bg-foreground/60 block h-2 w-0.5 origin-bottom rounded-t-full transition-all duration-100 ease-linear',
expanded ? 'group-hover/rail:rotate-40' : 'group-hover/rail:-rotate-40',
)}
/>
<span
aria-hidden
className={cn(
'bg-foreground/40 group-hover/rail:bg-foreground/60 block h-2 w-0.5 origin-top rounded-b-full transition-all duration-100 ease-linear',
expanded ? 'group-hover/rail:-rotate-40' : 'group-hover/rail:rotate-40',
)}
/>
</span>
<span className="border-border bg-foreground text-background pointer-events-none absolute left-full -ml-2 -translate-x-0.5 rounded-md border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap opacity-0 shadow-xs transition-all duration-200 ease-out group-hover/rail:translate-x-0 group-hover/rail:opacity-100">
{expanded ? 'Свернуть' : 'Развернуть'}
</span>
</button>
)
}
export function PreviewSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = usePreviewStore()
@@ -268,8 +311,8 @@ export function PreviewSidebar() {
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
<PreviewAppSwitcher />
<SidebarHeader className="pb-0">
<PreviewBrand />
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
@@ -318,9 +361,13 @@ export function PreviewSidebar() {
</div>
))}
</SidebarContent>
<SidebarFooter>
<PreviewNavUser />
<SidebarFooter className="px-1!">
<div className="px-2">
<Separator />
</div>
<PreviewSecondaryNav />
</SidebarFooter>
<SidebarRailToggle />
</Sidebar>
)
}
@@ -462,35 +509,51 @@ export function PreviewHeader() {
const current = NAV_ITEMS.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">{current?.label ?? 'Макет'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<header className="bg-background sticky top-0 z-20 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1 md:hidden" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to="/design-preview/apps" />}>{current?.group ?? 'Портал'}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{current?.label ?? 'Макет'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-1.5">
<FreshPill />
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
<Button type="button" variant="ghost" size="icon" aria-label="Командная палитра (⌘K)" onClick={() => setPaletteOpen(true)}>
<SearchIcon className="size-[18px]" aria-hidden />
</Button>
<FreshnessPopover />
<AppsMenu />
<PreviewUserMenu />
</div>
</header>
)
}
/** Подвал страницы как в app-shell-12. */
export function PreviewFooter() {
return (
<footer className="border-t px-4 py-2.5 md:px-6">
<div className="flex flex-col items-center justify-between gap-2 text-xs md:flex-row">
<span className="text-muted-foreground">2026 © Auth Portal · shnt.top</span>
<nav className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-3 gap-y-1" aria-label="Служебные ссылки">
<Link to="/design-preview/logins" className="hover:text-foreground">Журнал входов</Link>
<Link to="/design-preview/audit" className="hover:text-foreground">Изменения</Link>
<Link to="/design-preview/account" className="hover:text-foreground">Безопасность</Link>
</nav>
</div>
</footer>
)
}
type PaletteEntry = {
id: string
group: string
+14 -2
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { CommandPalette, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { CommandPalette, PreviewFooter, PreviewHeader, PreviewSidebar } from './preview-chrome'
import { UnsavedBar } from './unsaved-bar'
import './design-preview.css'
@@ -31,7 +32,17 @@ export function PreviewShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
<SidebarProvider
open={open}
onOpenChange={setOpen}
className={cn(
'[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]',
'[--sidebar-border:transparent]',
'[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]',
'[--sidebar-accent-foreground:var(--color-primary)]',
)}
style={{ '--sidebar-width': '240px' } as CSSProperties}
>
<a
href="#main-content"
className="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:ring-2"
@@ -44,6 +55,7 @@ export function PreviewShell({ children }: { children: ReactNode }) {
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 pb-24 md:gap-6 md:px-6 md:py-5 md:pb-24">
{children}
</div>
<PreviewFooter />
</SidebarInset>
<CommandPalette />
<UnsavedBar />