feat(web): добавить App Switcher для перехода между приложениями
Docker / build (push) Failing after 21s

Dropdown в sidebar с настраиваемым списком ссылок через VITE_APP_SWITCHER и дефолтами для VPS Tracker и CF Domain Manager.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 15:11:53 +07:00
co-authored by Cursor
parent 7c777bffc2
commit ea9c996838
6 changed files with 270 additions and 16 deletions
@@ -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('VPS Tracker')
})
})
+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 = 'vps-tracker'
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]!
}