Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/TopBar.tsx
T
Zimin A.N. 38f7dcee3e fix(topbar): restore full right cluster per user preference
User explicitly: "Руководство · dev · 19a3731b · ☀️🌙🖥 · RU EN · zimin.an · ↩
это оставь". Revert MR !81 cluster-minimization — diagnostic info нужна
для prod debugging.

Restored:
- Docs link (/docs/) — opens admin docs portal
- VersionBadge (channel + commit short SHA) — shows local/dev/v1.x.x · hash
- LanguageSwitch (dual RU | EN radio) — keeps both visible vs single-toggle
- (ThemeSwitch + AuthBadge остаются как были)
2026-05-11 19:55:52 +03:00

206 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
const LANG_OPTIONS = [
{ id: 'ru-RU', label: 'RU' },
{ id: 'en-US', label: 'EN' },
]
/**
* 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
*/
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 per redesign prototype:
* - Single crumb (catalog): "Справочники" (bold) + "40 шт." count
* - Detail route: "← Справочники / Космические аппараты satellites · v1.0.0"
* с back-arrow prefix на parent crumb (clickable). */}
<nav className="flex items-center gap-2 text-body min-w-0">
{breadcrumb.map((crumb, i) => {
const isLast = i === breadcrumb.length - 1
const isFirstOfTwoPlus = i === 0 && breadcrumb.length > 1
return (
<span key={i} className="flex items-center gap-2 min-w-0">
{i > 0 && <span className="text-mute text-title-md font-light">/</span>}
{crumb.to && !isLast ? (
<Link
to={crumb.to}
className={
isFirstOfTwoPlus
? 'text-cell text-mute hover:text-ink truncate transition-colors inline-flex items-center gap-1'
: 'text-ink-2 hover:text-ink truncate transition-colors'
}
>
{isFirstOfTwoPlus && <span aria-hidden></span>}
{crumb.label}
</Link>
) : (
<span className={isLast ? 'text-ink font-semibold 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-cell text-mute shrink-0 ml-1">{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 — user prefers full set: Docs link + VersionBadge +
* ThemeSwitch + LanguageSwitch + AuthBadge (logged-in avatar/sign-in).
* User explicitly: "Руководство · dev · {commit} · theme · RU EN ·
* zimin.an · logout это оставь". */}
<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>
<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
}