import { useTranslation } from 'react-i18next' import { Link, useLocation, useNavigate } from '@tanstack/react-router' 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 { 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() { const { t, i18n } = useTranslation() const navigate = useNavigate() const [searchValue, setSearchValue] = useState('') const inputRef = useRef(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 (
{/* Breadcrumb — left */} {/* Search — center, max-width per handoff */}
setSearchValue(e.target.value)} onClear={() => setSearchValue('')} aria-label={t('topbar.search.label')} /> {/* Right cluster */}
{t('nav.docs')} i18n.changeLanguage(id)} />
) } /** * Breadcrumb из current route — простой mapping для типичных страниц. */ function useBreadcrumb(): { label: string; to?: string }[] { const { t } = useTranslation() const location = useLocation() const path = location.pathname if (path === '/' || path === '') { return [{ label: t('nav.home') }] } const segments = path.split('/').filter(Boolean) const first = segments[0] const ROOT_LABELS: Record = { 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: { label: string; to?: string }[] = [ { label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` }, ] if (segments.length >= 2) { crumbs.push({ label: decodeURIComponent(segments[1]) }) } return crumbs }