From ea9c9968381212662a1e8b5e5affc68fddb4b5bb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 15:11:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20App=20Switcher=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=B6=D0=B4=D1=83=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropdown в sidebar с настраиваемым списком ссылок через VITE_APP_SWITCHER и дефолтами для VPS Tracker и CF Domain Manager. Co-authored-by: Cursor --- apps/web/.env.example | 5 + apps/web/src/components/app-switcher.tsx | 96 ++++++++++++++++++++ apps/web/src/components/layout/app-shell.tsx | 18 +--- apps/web/src/lib/app-switcher-config.test.ts | 71 +++++++++++++++ apps/web/src/lib/app-switcher-config.ts | 86 ++++++++++++++++++ apps/web/src/vite-env.d.ts | 10 ++ 6 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/components/app-switcher.tsx create mode 100644 apps/web/src/lib/app-switcher-config.test.ts create mode 100644 apps/web/src/lib/app-switcher-config.ts create mode 100644 apps/web/src/vite-env.d.ts diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..034c5df --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,5 @@ +# Frontend (Vite) +# VITE_API_URL= + +# Список приложений для 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"}]} diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx new file mode 100644 index 0000000..ec7e97d --- /dev/null +++ b/apps/web/src/components/app-switcher.tsx @@ -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 ( + + + + + } + > +
+ +
+
+ {current.name} + {current.subtitle ? ( + + {current.subtitle} + + ) : null} +
+ +
+ + + {config.menuLabel} + + + {config.apps.map((app) => { + const Icon = APP_SWITCHER_ICONS[app.icon] + const isCurrent = app.id === CURRENT_APP_ID + + if (isCurrent) { + return ( + + + {app.name} + + + ) + } + + return ( + }> + + {app.name} + {app.shortcut ? ( + {app.shortcut} + ) : null} + + ) + })} + + +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 4917cfb..bb35fa7 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query' import { useState, type ReactNode } from 'react' import { ModeToggle } from '@/components/mode-toggle' +import { AppSwitcher } from '@/components/app-switcher' import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search' import { dashboardStatsQueryOptions } from '@/queries/dashboard' import { formatRelativeSyncTime } from '@/lib/sync-format' @@ -147,22 +148,7 @@ export function AppShell({ children }: { children: ReactNode }) { - - - }> -
- -
-
- VPS Tracker - Учёт виртуальных серверов -
-
-
-
+
{navGroups.map((group) => ( diff --git a/apps/web/src/lib/app-switcher-config.test.ts b/apps/web/src/lib/app-switcher-config.test.ts new file mode 100644 index 0000000..dbd580a --- /dev/null +++ b/apps/web/src/lib/app-switcher-config.test.ts @@ -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') + }) +}) diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts new file mode 100644 index 0000000..8f08efa --- /dev/null +++ b/apps/web/src/lib/app-switcher-config.ts @@ -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 + +export const APP_SWITCHER_ICONS: Record = { + 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 +export type AppSwitcherConfig = z.infer + +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]! +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..8328ba9 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL?: string + readonly VITE_APP_SWITCHER?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}