fix(web): починить SpaceSwitcher #31 и добавить NavUser в footer
Docker / build (push) Failing after 21s

DropdownMenuLabel только внутри Group; аккаунт в SidebarFooter по app-shell-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 15:58:31 +07:00
co-authored by Cursor
parent e360efb885
commit 47c1f37912
5 changed files with 392 additions and 24 deletions
+4 -3
View File
@@ -46,10 +46,10 @@ import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState, type CSSProperties, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { NavUser } from '@/components/layout/nav-user'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
@@ -211,7 +211,9 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<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">
@@ -242,7 +244,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
+227
View File
@@ -0,0 +1,227 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
UsersIcon,
} from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import {
Avatar,
AvatarFallback,
} from '@cfdm/ui/components/avatar'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import {
can,
clearToken,
getClaims,
isAuthEnabled,
redirectToPortalLogin,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const claims = getClaims()
const authOn = isAuthEnabled()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен')
const fallback = initials(name, email)
function handleSignOut() {
clearToken()
redirectToPortalLogin()
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{can('vps:settings:admin') ? (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/spaces" />}
>
<UsersIcon aria-hidden />
Пространство
</DropdownMenuItem>
) : null}
{can('vps:settings:admin') ? (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" />}
>
<SettingsIcon aria-hidden />
Настройки
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
{authOn ? (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
import { CheckIcon, ChevronsUpDownIcon, PlusIcon, UsersIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -7,6 +7,7 @@ import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -25,6 +26,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import { api } from '@/lib/api-client'
@@ -32,6 +34,7 @@ import { getStoredSpaceId, setStoredSpaceId, type SpaceDto } from '@/lib/space'
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
export function SpaceSwitcher() {
const { isMobile } = useSidebar()
const qc = useQueryClient()
const { data: spaces = [] } = useQuery(spacesQueryOptions())
const currentId = getStoredSpaceId() ?? spaces[0]?.id
@@ -52,6 +55,10 @@ export function SpaceSwitcher() {
toast.success(`Пространство: ${space.name}`)
}
function openCreateDialog() {
queueMicrotask(() => setCreateOpen(true))
}
async function handleCreate() {
const n = name.trim()
if (!n) return
@@ -80,10 +87,23 @@ export function SpaceSwitcher() {
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />}
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-muted"
/>
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-muted text-muted-foreground"
aria-hidden
>
<UsersIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{current?.name ?? 'Пространство'}</span>
<span className="truncate font-semibold">
{current?.name ?? 'Пространство'}
</span>
<span className="truncate text-xs text-muted-foreground">
{current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''}
@@ -91,22 +111,34 @@ export function SpaceSwitcher() {
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56 rounded-lg" align="start" sideOffset={4}>
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => (
<DropdownMenuItem
key={s.id}
onClick={() => selectSpace(s)}
className={s.id === current?.id ? 'bg-accent' : undefined}
>
<span className="truncate">{s.name}</span>
</DropdownMenuItem>
))}
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel>Пространства</DropdownMenuLabel>
{spaces.map((s) => (
<DropdownMenuItem
key={s.id}
onClick={() => selectSpace(s)}
>
<UsersIcon className="size-4" />
<span className="truncate">{s.name}</span>
{s.id === current?.id ? (
<CheckIcon className="ml-auto size-4" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Создать пространство
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem onClick={openCreateDialog}>
<PlusIcon className="size-4" />
Создать пространство
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
+4 -3
View File
@@ -59,12 +59,13 @@ Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css`**без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu****SystemMonitorPopover** **ModeToggle** (без Search в chrome) |
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4`**пустой** `SidebarFooter` |
| Header right | **AppsMenu****SystemMonitorPopover** (без Search / ModeToggle в chrome) |
| Sidebar | AppSwitcher → SpaceSwitcher → groups (`SidebarGroupContent`) → icons `size-4`**NavUser** в `SidebarFooter` ([app-shell-1](https://reui.io/preview/base/app-shell-1)) |
| Theme | segmented toggle **внутри NavUser** (не ModeToggle в header) |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
Запрещено в chrome: `SidebarRail`, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`, ModeToggle в header (тема только в NavUser).
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
+107
View File
@@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@cfdm/ui/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}