From 39fac7834f929066593dd163a2d858f338b77de7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 20 Aug 2026 12:28:06 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BD=D0=B5=20=D0=B4=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=BA=D1=83=20=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../web/src/components/layout/site-header.tsx | 87 +++---------------- apps/web/src/lib/breadcrumbs.test.ts | 42 +++++++++ apps/web/src/lib/breadcrumbs.ts | 86 ++++++++++++++++++ 3 files changed, 138 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/lib/breadcrumbs.test.ts create mode 100644 apps/web/src/lib/breadcrumbs.ts diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index e6f4572..29ba086 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -1,5 +1,5 @@ +import { Fragment, useMemo } from 'react' import { Link, useMatches, useRouterState } from '@tanstack/react-router' -import { useMemo } from 'react' import { Breadcrumb, BreadcrumbItem, @@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppsMenu } from '@/components/layout/apps-menu' import { SidebarTrigger } from '@cfdm/ui/components/sidebar' +import { getBreadcrumbs } from '@/lib/breadcrumbs' export interface RouteBreadcrumbLoaderData { breadcrumb?: string } -const routeTitles: Record = { - '/': 'Панель управления', - '/domains': 'Домены', - '/groups': 'Группы доменов', - '/services': 'Сервисы', - '/certificates': 'Сертификаты', - '/settings/appearance': 'Внешний вид', - '/settings/health': 'Health-check', - '/settings/integrations': 'Интеграции', -} - -function getBreadcrumbs( - pathname: string, - dynamicLabels: Record, -) { - 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() { const matches = useMatches() return useMemo(() => { @@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() { export function SiteHeader() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const dynamicLabels = useDynamicBreadcrumbLabels() - const crumbs = getBreadcrumbs(pathname, dynamicLabels) + const crumbs = useMemo( + () => getBreadcrumbs(pathname, dynamicLabels), + [pathname, dynamicLabels], + ) return (
@@ -117,10 +50,10 @@ export function SiteHeader() { {crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1 return ( - - {index > 0 && ( + + {index > 0 ? ( - )} + ) : null} @@ -132,7 +65,7 @@ export function SiteHeader() { )} - + ) })} diff --git a/apps/web/src/lib/breadcrumbs.test.ts b/apps/web/src/lib/breadcrumbs.test.ts new file mode 100644 index 0000000..d77ca3c --- /dev/null +++ b/apps/web/src/lib/breadcrumbs.test.ts @@ -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' }, + ]) + }) +}) diff --git a/apps/web/src/lib/breadcrumbs.ts b/apps/web/src/lib/breadcrumbs.ts new file mode 100644 index 0000000..6ba51be --- /dev/null +++ b/apps/web/src/lib/breadcrumbs.ts @@ -0,0 +1,86 @@ +export interface BreadcrumbCrumb { + label: string + href: string +} + +const routeTitles: Record = { + '/': 'Панель управления', + '/domains': 'Домены', + '/groups': 'Группы доменов', + '/services': 'Сервисы', + '/certificates': 'Сертификаты', +} + +const SETTINGS_SECTIONS: Record = { + '/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 = {}, +): 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: '/' }] +}