feat(web): добавить App Switcher для перехода между приложениями
Docker / build (push) Failing after 21s
Docker / build (push) Failing after 21s
Dropdown в sidebar с настраиваемым списком ссылок через VITE_APP_SWITCHER и дефолтами для VPS Tracker и CF Domain Manager. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"}]}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" render={<Link to="/dashboard" />}>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">VPS Tracker</span>
|
||||
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{navGroups.map((group) => (
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]!
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
readonly VITE_APP_SWITCHER?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user