diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx
new file mode 100644
index 0000000..333f2b3
--- /dev/null
+++ b/apps/web/src/components/layout/nav-user.tsx
@@ -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: ,
+ },
+ {
+ value: 'dark',
+ label: 'Тёмная',
+ icon: ,
+ },
+ {
+ value: 'system',
+ label: 'Системная',
+ icon: ,
+ },
+] as const
+
+function ThemeSegmentedToggle() {
+ const { theme, setTheme } = useTheme()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ const currentTheme = mounted ? (theme ?? 'system') : 'system'
+
+ return (
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ {THEMES.map(({ value, label, icon }) => {
+ const isActive = currentTheme === value
+ return (
+
+ )
+ })}
+
+ )
+}
+
+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 (
+
+
+
+
+ }
+ >
+
+
+ {fallback}
+
+
+
+ {name}
+
+ {email || '—'}
+
+
+
+
+
+
+
+
+
+ {fallback}
+
+
+
+ {name}
+
+ {email || '—'}
+
+
+
+
+
+
+
+
+ {can('vps:settings:admin') ? (
+ }
+ >
+
+ Пространство
+
+ ) : null}
+ {can('vps:settings:admin') ? (
+ }
+ >
+
+ Настройки
+
+ ) : null}
+
+
+ Тема
+
+
+
+
+
+
+ {authOn ? (
+ <>
+
+
+
+
+ Выйти
+
+
+ >
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/layout/space-switcher.tsx b/apps/web/src/components/layout/space-switcher.tsx
index de139dd..c872b6f 100644
--- a/apps/web/src/components/layout/space-switcher.tsx
+++ b/apps/web/src/components/layout/space-switcher.tsx
@@ -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() {
}
+ render={
+
+ }
>
+
+
+
- {current?.name ?? 'Пространство'}
+
+ {current?.name ?? 'Пространство'}
+
{current?.kind === 'main' ? 'Основное' : 'Личное'}
{current?.role ? ` · ${current.role}` : ''}
@@ -91,22 +111,34 @@ export function SpaceSwitcher() {
-
- Пространства
- {spaces.map((s) => (
- selectSpace(s)}
- className={s.id === current?.id ? 'bg-accent' : undefined}
- >
- {s.name}
-
- ))}
+
+
+ Пространства
+ {spaces.map((s) => (
+ selectSpace(s)}
+ >
+
+ {s.name}
+ {s.id === current?.id ? (
+
+ ) : null}
+
+ ))}
+
- setCreateOpen(true)}>
-
- Создать пространство
-
+
+
+
+ Создать пространство
+
+
diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md
index 1259213..3bc3638 100644
--- a/docs/ui-design-contract.md
+++ b/docs/ui-design-contract.md
@@ -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.
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
new file mode 100644
index 0000000..ba291ec
--- /dev/null
+++ b/packages/ui/src/components/avatar.tsx
@@ -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 (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ 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 (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ 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,
+}