fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
|
||||||
'/': 'Панель управления',
|
|
||||||
'/domains': 'Домены',
|
|
||||||
'/groups': 'Группы доменов',
|
|
||||||
'/services': 'Сервисы',
|
|
||||||
'/certificates': 'Сертификаты',
|
|
||||||
'/settings/appearance': 'Внешний вид',
|
|
||||||
'/settings/health': 'Health-check',
|
|
||||||
'/settings/integrations': 'Интеграции',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBreadcrumbs(
|
|
||||||
pathname: string,
|
|
||||||
dynamicLabels: Record<string, string>,
|
|
||||||
) {
|
|
||||||
if (pathname === '/') {
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/services\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Сервисы', href: '/services' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
|
||||||
const domainId = pathname.split('/')[2]
|
|
||||||
const domainPath = `/domains/${domainId}`
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
|
||||||
{ label: 'DNS', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/settings')) {
|
|
||||||
return [
|
|
||||||
{ label: 'Настройки', href: '/settings/appearance' },
|
|
||||||
...(pathname === '/settings/integrations'
|
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
|
||||||
: pathname === '/settings/health'
|
|
||||||
? [{ label: 'Health-check', href: pathname }]
|
|
||||||
: pathname === '/settings/appearance'
|
|
||||||
? [{ label: 'Внешний вид', href: pathname }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
|
||||||
if (title) {
|
|
||||||
return [{ label: title, href: pathname }]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
const crumbs = useMemo(
|
||||||
|
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||||
|
[pathname, dynamicLabels],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
@@ -117,10 +50,10 @@ export function SiteHeader() {
|
|||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.href} className="contents">
|
<Fragment key={`${index}-${crumb.href}`}>
|
||||||
{index > 0 && (
|
{index > 0 ? (
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
)}
|
) : null}
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||||
>
|
>
|
||||||
@@ -132,7 +65,7 @@ export function SiteHeader() {
|
|||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</span>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
|
||||||
|
|
||||||
|
describe('getBreadcrumbs', () => {
|
||||||
|
it('keeps a single Настройки parent plus the active section', () => {
|
||||||
|
expect(getBreadcrumbs('/settings/appearance')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/health')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Health-check', href: '/settings/health' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/integrations')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Интеграции', href: '/settings/integrations' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reuse the section href for the parent crumb', () => {
|
||||||
|
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||||
|
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||||
|
expect(new Set(hrefs).size).toBe(hrefs.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dedupeBreadcrumbs', () => {
|
||||||
|
it('collapses stacked identical labels from repeated navigations', () => {
|
||||||
|
expect(
|
||||||
|
dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface BreadcrumbCrumb {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeTitles: Record<string, string> = {
|
||||||
|
'/': 'Панель управления',
|
||||||
|
'/domains': 'Домены',
|
||||||
|
'/groups': 'Группы доменов',
|
||||||
|
'/services': 'Сервисы',
|
||||||
|
'/certificates': 'Сертификаты',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||||
|
'/settings/appearance': 'Внешний вид',
|
||||||
|
'/settings/health': 'Health-check',
|
||||||
|
'/settings/integrations': 'Интеграции',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||||
|
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
|
||||||
|
const out: BreadcrumbCrumb[] = []
|
||||||
|
for (const crumb of crumbs) {
|
||||||
|
const prev = out.at(-1)
|
||||||
|
if (prev && prev.label === crumb.label) continue
|
||||||
|
out.push(crumb)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBreadcrumbs(
|
||||||
|
pathname: string,
|
||||||
|
dynamicLabels: Record<string, string> = {},
|
||||||
|
): BreadcrumbCrumb[] {
|
||||||
|
const path = pathname.replace(/\/+$/, '') || '/'
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/groups\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Группа', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+\/dns$/)) {
|
||||||
|
const domainId = path.split('/')[2]
|
||||||
|
const domainPath = `/domains/${domainId}`
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||||
|
{ label: 'DNS', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/settings' || path.startsWith('/settings/')) {
|
||||||
|
const section = SETTINGS_SECTIONS[path]
|
||||||
|
return dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
...(section ? [{ label: section, href: path }] : []),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = routeTitles[path]
|
||||||
|
if (title) {
|
||||||
|
return [{ label: title, href: path }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user