feat(editor): match redesign prototype layout — TopBar title + tab counts + InfoPanel actions

Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:

1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
   - Time-travel + AOI → InfoPanel bottom (close к data context)
   - Schema edit + +Запись → tab toolbar right (close к tab actions)

Changes:

src/components/layout/TopBar.tsx
  - Breadcrumb получил subtitle slot — mono '<name> · v<version>'
  - useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
  - Render: '← Справочники / Космические аппараты satellites · v1.0.0'

src/api/queries.ts
  - useDictionaryDetail принимает name: string | undefined + enabled flag
  - Guard для editor breadcrumb когда route не matches dict

src/routes/dictionaries.$name.tsx
  - Removed <PageHeader> — title/breadcrumb теперь в TopBar
  - EditorInfoPanel получает actions prop:
    onTimeTravel + timeTravelActive + onAoi + aoiActive
  - EditorTabBar получает counts (per-tab badge) + actions slot
    (right-aligned Schema + +Запись buttons)
  - extractOutgoingFkCount helper для relations count

src/components/editor/EditorInfoPanel.tsx
  - actions prop с Time-travel + AOI buttons
  - Rendered в panel выше Relations section, между metadata и FK list
  - aria-pressed когда active (time-travel или AOI с filter)

src/components/editor/EditorTabBar (inline in route)
  - counts prop: Partial<Record<EditorTab, number>>
  - actions prop: ReactNode для right-aligned cluster
  - inline count badge — text-mono text-mute next to label

Tests: 116 pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 17:45:06 +03:00
parent ac80c3f4a1
commit ec99010697
4 changed files with 152 additions and 87 deletions
@@ -1,9 +1,11 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
import { Badge } from '@/ui'
import { useDictionaryDependents } from '@/api/queries'
import type { DictionaryDetail } from '@/api/client'
import { cn } from '@/lib/utils'
/**
* EditorInfoPanel — left rail с dict metadata per handoff prototype Screen 2.
@@ -23,9 +25,17 @@ import type { DictionaryDetail } from '@/api/client'
export type EditorInfoPanelProps = {
detail: DictionaryDetail
recordCount: number
/** Optional action buttons (Time-travel / AOI filter) — bottom of panel
* per redesign prototype. Toggle handlers come from route's state. */
actions?: {
onTimeTravel?: () => void
timeTravelActive?: boolean
onAoi?: () => void
aoiActive?: boolean
}
}
export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPanelProps) {
const { t } = useTranslation()
const { data: refByRaw } = useDictionaryDependents(detail.name)
@@ -64,6 +74,44 @@ export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
</MetaCell>
</div>
{/* Actions — bottom buttons per redesign prototype: Time-travel + AOI */}
{actions && (
<div className="space-y-2 pt-2 border-t border-line-2">
{actions.onTimeTravel && (
<button
type="button"
onClick={actions.onTimeTravel}
aria-pressed={actions.timeTravelActive}
className={cn(
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
actions.timeTravelActive
? 'border-accent bg-accent-bg text-accent'
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
)}
>
<ClockCounterClockwiseIcon weight="regular" size={14} />
{t('timeTravel.button', { defaultValue: 'Time-travel…' })}
</button>
)}
{actions.onAoi && (
<button
type="button"
onClick={actions.onAoi}
aria-pressed={actions.aoiActive}
className={cn(
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
actions.aoiActive
? 'border-accent bg-accent-bg text-accent'
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
)}
>
<MapTrifoldIcon weight="regular" size={14} />
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
</button>
)}
</div>
)}
{/* Relations */}
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
<Section
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useDictionaryDetail } from '@/api/queries'
import { LanguageSwitch } from '@/ui'
import { AuthBadge } from '@/auth/AuthBadge'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
@@ -72,7 +73,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
</button>
)}
{/* Breadcrumb — left */}
{/* Breadcrumb — left. Editor route ('/dictionaries/$name') получает
* augmented breadcrumb с displayName + version (per redesign prototype).
* Остальные routes: regular path-based breadcrumb. */}
<nav className="flex items-center gap-1.5 text-body min-w-0">
{breadcrumb.map((crumb, i) => {
const isLast = i === breadcrumb.length - 1
@@ -91,6 +94,10 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
{crumb.label}
</span>
)}
{/* Editor route: inline mono "<name> · v<version>" after title */}
{isLast && crumb.subtitle && (
<span className="text-mono text-mute shrink-0 ml-2">{crumb.subtitle}</span>
)}
</span>
)
})}
@@ -134,14 +141,22 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
)
}
type Crumb = { label: string; to?: string; subtitle?: string }
/**
* Breadcrumb из current route — простой mapping для типичных страниц.
* Breadcrumb из current route. Для editor route ('/dictionaries/$name')
* добавляет displayName + version (mono subtitle) per redesign prototype.
*/
function useBreadcrumb(): { label: string; to?: string }[] {
function useBreadcrumb(): Crumb[] {
const { t } = useTranslation()
const location = useLocation()
const path = location.pathname
// Editor route: fetch dict detail для display name + schema version.
const editorMatch = path.match(/^\/dictionaries\/([^/]+)/)
const editorName = editorMatch ? decodeURIComponent(editorMatch[1]) : undefined
const editorDict = useDictionaryDetail(editorName)
if (path === '/' || path === '') {
return [{ label: t('nav.home') }]
}
@@ -161,12 +176,20 @@ function useBreadcrumb(): { label: string; to?: string }[] {
}
const rootLabel = ROOT_LABELS[first] ?? first
const crumbs: { label: string; to?: string }[] = [
const crumbs: Crumb[] = [
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
]
if (segments.length >= 2) {
crumbs.push({ label: decodeURIComponent(segments[1]) })
// Editor route enrichment: displayName + version
if (editorName && editorDict.data) {
crumbs.push({
label: editorDict.data.displayName ?? editorDict.data.name,
subtitle: `${editorDict.data.name} · v${editorDict.data.schemaVersion}`,
})
} else {
crumbs.push({ label: decodeURIComponent(segments[1]) })
}
}
return crumbs