feat(web): добавить App Switcher для перехода между приложениями
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m33s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

Dropdown в sidebar с настраиваемым списком ссылок через VITE_APP_SWITCHER; dev-порт 5174 для перехода с VPS Tracker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-30 15:11:55 +07:00
co-authored by Cursor
parent 72a4b3403b
commit e707766418
7 changed files with 269 additions and 17 deletions
+4
View File
@@ -11,6 +11,10 @@ ADMIN_USERNAME=admin
# Leave empty for dev default password "admin"
ADMIN_PASSWORD_HASH=
# Frontend (Vite)
# Список приложений для sidebar switcher (JSON, опционально)
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[{"id":"vps-tracker","name":"VPS Tracker","subtitle":"Учёт VPS","url":"http://localhost:5173","icon":"server"},{"id":"cfdm","name":"CF Domain Manager","subtitle":"Домены","url":"http://localhost:5174","icon":"cloud"},{"id":"grafana","name":"Grafana","url":"https://grafana.example.com","icon":"chart"}]}
# Server
SERVER_PORT=8080
STATIC_DIR=
+2 -17
View File
@@ -5,8 +5,8 @@ import {
FolderTreeIcon,
ServerIcon,
ShieldCheckIcon,
CloudIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import {
Sidebar,
@@ -73,22 +73,7 @@ export function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" render={<Link to="/" />}>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CloudIcon className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">CF Domain Manager</span>
<span className="text-xs text-muted-foreground">Управление доменами</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} />
+96
View File
@@ -0,0 +1,96 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@cfdm/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getAppSwitcherConfig,
getCurrentApp,
} from '@/lib/app-switcher-config'
export function AppSwitcher() {
const { isMobile } = useSidebar()
const config = getAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{config.menuLabel}
</DropdownMenuLabel>
<DropdownMenuGroup>
{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>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem key={app.id} render={<a href={app.url} />}>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CURRENT_APP_ID,
DEFAULT_APP_SWITCHER_CONFIG,
getCurrentApp,
parseAppSwitcherConfig,
} from '@/lib/app-switcher-config'
describe('parseAppSwitcherConfig', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('возвращает дефолты при пустом значении', () => {
expect(parseAppSwitcherConfig()).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
expect(parseAppSwitcherConfig('')).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
})
it('дефолты содержат оба приложения', () => {
const ids = DEFAULT_APP_SWITCHER_CONFIG.apps.map((app) => app.id)
expect(ids).toContain('vps-tracker')
expect(ids).toContain('cfdm')
})
it('парсит override из JSON', () => {
const raw = JSON.stringify({
menuLabel: 'Сервисы',
apps: [
{
id: 'vps-tracker',
name: 'VPS',
url: 'http://localhost:5173',
icon: 'server',
},
{
id: 'cfdm',
name: 'CFDM',
url: 'http://localhost:5174',
icon: 'cloud',
},
{
id: 'grafana',
name: 'Grafana',
url: 'https://grafana.example.com',
icon: 'chart',
},
],
})
const config = parseAppSwitcherConfig(raw)
expect(config.menuLabel).toBe('Сервисы')
expect(config.apps).toHaveLength(3)
expect(config.apps[2]?.name).toBe('Grafana')
})
it('при невалидном JSON возвращает дефолты и пишет предупреждение', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseAppSwitcherConfig('{invalid')).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
expect(warnSpy).toHaveBeenCalled()
})
})
describe('getCurrentApp', () => {
it('находит текущее приложение по CURRENT_APP_ID', () => {
const current = getCurrentApp(DEFAULT_APP_SWITCHER_CONFIG)
expect(current.id).toBe(CURRENT_APP_ID)
expect(current.name).toBe('CF Domain Manager')
})
})
+86
View File
@@ -0,0 +1,86 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { z } from 'zod'
export const CURRENT_APP_ID = 'cfdm'
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string(),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps-tracker',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://localhost:5173',
icon: 'server',
shortcut: '⌘1',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'http://localhost:5174',
icon: 'cloud',
shortcut: '⌘2',
},
],
}
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
if (!raw?.trim()) {
return DEFAULT_APP_SWITCHER_CONFIG
}
try {
const parsed = JSON.parse(raw) as unknown
return appSwitcherConfigSchema.parse(parsed)
} catch (error) {
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
return DEFAULT_APP_SWITCHER_CONFIG
}
}
export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(),
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
},
},
server: {
port: 5174,
fs: {
allow: [path.resolve(__dirname, '../..')],
},