import { useTranslation } from 'react-i18next' import { Link, useLocation, useNavigate } from '@tanstack/react-router' import { useDictionaries, useDictionaryDetail } from '@/api/queries' import { VersionBadge } from '@/components/version/VersionBadge' import { SearchInput } from '@/ui/components/search-input' import { Bell, 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 */ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) { const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() const isCatalog = location.pathname === '/dictionaries' const catalogQ = isCatalog ? ((location.search as Record)?.q as string | undefined) ?? '' : '' const [searchValue, setSearchValue] = useState(catalogQ) const inputRef = useRef(null) const breadcrumb = useBreadcrumb() // Sync local input value c URL `?q=` when navigating between routes. useEffect(() => { setSearchValue(catalogQ) }, [catalogQ]) // ⌘K / Ctrl+K focus shortcut. 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')) { if (target === inputRef.current) return } e.preventDefault() inputRef.current?.focus() inputRef.current?.select() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, []) // Context-aware search: // - На /dictionaries — live filter каталога через URL `?q=` // - На остальных routes — local state, submit redirect'ит на /search const handleSearchInput = (e: React.ChangeEvent) => { const v = e.target.value setSearchValue(v) if (isCatalog) { void navigate({ to: '/dictionaries', search: (prev: Record) => ({ ...prev, q: v.length > 0 ? v : undefined, }), replace: true, }) } } const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault() const q = searchValue.trim() if (q.length === 0) return if (isCatalog) return void navigate({ to: '/search', search: { q } }) } const handleSearchClear = () => { setSearchValue('') if (isCatalog) { void navigate({ to: '/dictionaries', search: (prev: Record) => { const { q: _q, ...rest } = prev return rest }, replace: true, }) } } return (
{/* Hamburger (lg:hidden) — toggle MobileSidebar */} {onMenuClick && ( )} {/* Breadcrumb per redesign prototype: * - Single crumb (catalog): "Справочники" (bold) + "40 шт." count * - Detail route: "← Справочники / Космические аппараты satellites · v1.0.0" * с back-arrow prefix на parent crumb (clickable). */} {/* Search center — context-aware (catalog mode = live filter, else = * records search submit). Двойной X fixed в SearchInput component * (type="text" вместо "search" — убрал native browser X). */}
{/* Right cluster — broadcast slot per user: "руководство и версию * оставить в топбар чтобы подсвечивать новинки. туда же и сообщения". * Docs + VersionBadge + Notifications. ThemeSwitch / Lang / Auth * переехали в Sidebar footer. */}
) } /** * Placeholder notifications bell — будет показывать N unread сообщений * (webhook errors, draft reviews, system notices). Click → drawer/popup. * Сейчас static bell без counter — wire'ится когда backend notifications * stream появится. */ function NotificationsBell() { const { t } = useTranslation() return ( ) } type Crumb = { label: string to?: string /** Mono-styled secondary text (editor route: "name · v1.0.0"). */ subtitle?: string /** Cap-styled count badge (catalog route: "40 шт."). */ caption?: 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) // Catalog route: total dict count для inline "N шт." subtitle на breadcrumb. // Запрос cached глобально (useDictionaries) — не вызывает extra fetch. const isCatalog = path === '/dictionaries' const dictsQuery = useDictionaries() const dictsCount = dictsQuery.data?.length 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 // Catalog: title "Справочники" + inline caption "N шт." per user feedback. if (isCatalog) { return [ { label: rootLabel, caption: typeof dictsCount === 'number' ? t('dict.list.countShort', { count: dictsCount, defaultValue: `${dictsCount} шт.`, }) : undefined, }, ] } 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 }