feat(layout): подключить chrome app-shell-12 в AppShell
SearchMenu (Cmd+K) и AppsMenu из конфига App Switcher в SiteHeader; sidebar без изменений. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
/** Header apps grid — app-shell-12 AppsMenu, wired to CFDM App Switcher. */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings/integrations" />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить приложения
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@cfdm/ui/components/dialog'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель управления',
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: ['domains', 'зоны', 'cloudflare'],
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы доменов',
|
||||
keywords: ['groups', 'группы', 'канбан'],
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: ['services', 'сервисы', 'fqdn'],
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: ['certificates', 'ssl', 'tls'],
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
label: 'Настройки',
|
||||
keywords: ['settings', 'интеграции', 'app switcher'],
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Command-K search — app-shell-12 SearchMenu, routes only (no demo data). */
|
||||
export function SearchMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
@@ -94,8 +96,9 @@ export function SiteHeader() {
|
||||
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumb>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
@@ -120,7 +123,10 @@ export function SiteHeader() {
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="text-muted-foreground ml-auto flex items-center gap-2">
|
||||
</div>
|
||||
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
|
||||
<SearchMenu />
|
||||
<AppsMenu />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user