diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index a873912..126cdc7 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 }) { ))} - + + +
@@ -242,7 +244,6 @@ export function AppShell({ children }: { children: ReactNode }) {
-
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, +}