fix(web): починить SpaceSwitcher #31 и добавить NavUser в footer
Docker / build (push) Failing after 21s
Docker / build (push) Failing after 21s
DropdownMenuLabel только внутри Group; аккаунт в SidebarFooter по app-shell-1. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user