158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
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<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">
|
|
{/* Breadcrumb — left */}
|
|
<nav className="flex items-center gap-1.5 text-sm 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-xs">/</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>
|
|
)}
|
|
</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-xs text-ink-2 hover:text-ink transition-colors"
|
|
>
|
|
{t('nav.docs')}
|
|
</a>
|
|
<VersionBadge />
|
|
<ThemeSwitch />
|
|
<LanguageSwitch
|
|
value={i18n.language}
|
|
options={LANG_OPTIONS}
|
|
onChange={(id) => i18n.changeLanguage(id)}
|
|
/>
|
|
<AuthBadge />
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<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: { label: string; to?: string }[] = [
|
|
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
|
|
]
|
|
|
|
if (segments.length >= 2) {
|
|
crumbs.push({ label: decodeURIComponent(segments[1]) })
|
|
}
|
|
|
|
return crumbs
|
|
}
|