ec99010697
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.
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
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'
|
||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||
import { SearchInput } from '@/ui/components/search-input'
|
||
import { Menu } from 'lucide-react'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
|
||
/**
|
||
* TopBar — sticky header (h=56) per handoff design.
|
||
*
|
||
* Structure:
|
||
* - Left: dynamic breadcrumb based on current route
|
||
* - Center: global SearchInput → submits → navigates to /search?q=
|
||
* + ⌘K / Ctrl+K shortcut focuses input from anywhere
|
||
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
|
||
*/
|
||
const LANG_OPTIONS = [
|
||
{ id: 'ru-RU', label: 'RU' },
|
||
{ id: 'en-US', label: 'EN' },
|
||
]
|
||
|
||
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||
const { t, i18n } = useTranslation()
|
||
const navigate = useNavigate()
|
||
const [searchValue, setSearchValue] = useState('')
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const breadcrumb = useBreadcrumb()
|
||
|
||
// ⌘K / Ctrl+K shortcut — focus search input from anywhere. Skip когда
|
||
// активен другой input/textarea (юзер печатает в record edit form).
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||
const target = e.target as HTMLElement
|
||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||
// Уже в input — пусть default browser shortcut работает.
|
||
if (target === inputRef.current) return
|
||
}
|
||
e.preventDefault()
|
||
inputRef.current?.focus()
|
||
inputRef.current?.select()
|
||
}
|
||
}
|
||
window.addEventListener('keydown', handler)
|
||
return () => window.removeEventListener('keydown', handler)
|
||
}, [])
|
||
|
||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
const q = searchValue.trim()
|
||
if (q.length === 0) return
|
||
// Navigate даже если q < 3 — search route сам покажет "min 3 chars" hint.
|
||
// Раньше блокировал в TopBar → юзер думал «поиск не работает».
|
||
void navigate({ to: '/search', search: { q } })
|
||
}
|
||
|
||
return (
|
||
<header className="h-14 shrink-0 border-b border-line bg-surface flex items-center px-4 sm:px-6 gap-3">
|
||
{/* Hamburger (lg:hidden) — toggle MobileSidebar */}
|
||
{onMenuClick && (
|
||
<button
|
||
type="button"
|
||
aria-label={t('nav.menu', { defaultValue: 'Меню' })}
|
||
onClick={onMenuClick}
|
||
className="lg:hidden -ml-1 p-1.5 rounded-sm text-ink-2 hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||
>
|
||
<Menu size={20} strokeWidth={1.75} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 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
|
||
return (
|
||
<span key={i} className="flex items-center gap-1.5 min-w-0">
|
||
{i > 0 && <span className="text-mute text-cell">/</span>}
|
||
{crumb.to && !isLast ? (
|
||
<Link
|
||
to={crumb.to}
|
||
className="text-ink-2 hover:text-ink truncate transition-colors"
|
||
>
|
||
{crumb.label}
|
||
</Link>
|
||
) : (
|
||
<span className={isLast ? 'text-ink font-medium truncate' : 'text-ink-2 truncate'}>
|
||
{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>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{/* Search — center, max-width per handoff */}
|
||
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
|
||
<SearchInput
|
||
ref={inputRef}
|
||
placeholder={t('topbar.search.placeholder')}
|
||
value={searchValue}
|
||
onChange={(e) => setSearchValue(e.target.value)}
|
||
onClear={() => setSearchValue('')}
|
||
aria-label={t('topbar.search.label')}
|
||
/>
|
||
</form>
|
||
|
||
{/* Right cluster */}
|
||
<div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0">
|
||
<a
|
||
href="/docs/"
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="hidden sm:inline text-cell text-ink-2 hover:text-ink transition-colors"
|
||
>
|
||
{t('nav.docs')}
|
||
</a>
|
||
{/* VersionBadge hide на narrow viewport — diagnostic info, не critical UX */}
|
||
<span className="hidden md:inline-flex">
|
||
<VersionBadge />
|
||
</span>
|
||
<ThemeSwitch />
|
||
<LanguageSwitch
|
||
value={i18n.language}
|
||
options={LANG_OPTIONS}
|
||
onChange={(id) => i18n.changeLanguage(id)}
|
||
/>
|
||
<AuthBadge />
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
type Crumb = { label: string; to?: string; subtitle?: string }
|
||
|
||
/**
|
||
* Breadcrumb из current route. Для editor route ('/dictionaries/$name')
|
||
* добавляет displayName + version (mono subtitle) per redesign prototype.
|
||
*/
|
||
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') }]
|
||
}
|
||
|
||
const segments = path.split('/').filter(Boolean)
|
||
const first = segments[0]
|
||
|
||
const ROOT_LABELS: Record<string, string> = {
|
||
dictionaries: t('nav.dictionaries'),
|
||
search: t('nav.search'),
|
||
graph: t('nav.graph'),
|
||
'my-drafts': t('nav.myDrafts'),
|
||
reviews: t('nav.reviews'),
|
||
audit: t('nav.audit'),
|
||
outbox: t('nav.outbox'),
|
||
webhooks: t('nav.webhooks'),
|
||
}
|
||
|
||
const rootLabel = ROOT_LABELS[first] ?? first
|
||
const crumbs: Crumb[] = [
|
||
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
|
||
]
|
||
|
||
if (segments.length >= 2) {
|
||
// 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
|
||
}
|