feat(design-preview): каркас на блоке app-shell-12 из ReUI PRO
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
f97604c23e
commit
609bea6ad7
2 files changed
+176
-101
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user