7e44df3493
Follow-up к MR !90 per user feedback iterations: 1) "в поиске по справочникам два крестика" — fixed в SearchInput: `type="search"` (native browser X) → `type="text"`. Custom onClear X остаётся единственным. Catalog input тоже type="text". 2) "блин два крестика было на топбар поиске а тут норм все. верни" — restore TopBar search input. Context-aware behavior сохранён: - На /dictionaries → live filter каталога через URL ?q= - На остальных routes → submit (Enter) → /search records search Single X confirmed by visual test. 3) "свитч темы можно тоже в профиль перенести" — ThemeSwitch переехал в Sidebar footer (рядом с RU/EN button). Right row в footer: lang button слева, theme switch (3 icons sun/moon/monitor) справа через ml-auto. 4) "руководство и версию оставить в топбар чтобы подсвечивать новинки" — Docs + VersionBadge stays in TopBar. Туда же добавлена NotificationsBell (placeholder Bell icon) — broadcast slot для webhook errors / draft reviews / system notices когда backend notifications stream появится.
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<string, unknown>)?.q as string | undefined) ?? ''
|
||
: ''
|
||
const [searchValue, setSearchValue] = useState(catalogQ)
|
||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||
const v = e.target.value
|
||
setSearchValue(v)
|
||
if (isCatalog) {
|
||
void navigate({
|
||
to: '/dictionaries',
|
||
search: (prev: Record<string, unknown>) => ({
|
||
...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<string, unknown>) => {
|
||
const { q: _q, ...rest } = prev
|
||
return rest
|
||
},
|
||
replace: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
return (
|
||
<header className="h-11 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>
|
||
)}
|
||
{/* Catalog route: inline cap "N шт." count badge after title */}
|
||
{isLast && crumb.caption && (
|
||
<span className="text-cap text-mute shrink-0 ml-1">{crumb.caption}</span>
|
||
)}
|
||
</span>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{/* Search center — context-aware (catalog mode = live filter, else =
|
||
* records search submit). Двойной X fixed в SearchInput component
|
||
* (type="text" вместо "search" — убрал native browser X). */}
|
||
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
|
||
<SearchInput
|
||
ref={inputRef}
|
||
placeholder={
|
||
isCatalog
|
||
? t('topbar.search.catalog', { defaultValue: 'Поиск по справочникам, ⌘K' })
|
||
: t('topbar.search.placeholder')
|
||
}
|
||
value={searchValue}
|
||
onChange={handleSearchInput}
|
||
onClear={handleSearchClear}
|
||
aria-label={t('topbar.search.label')}
|
||
className="!h-7 text-cell"
|
||
/>
|
||
</form>
|
||
|
||
{/* Right cluster — broadcast slot per user: "руководство и версию
|
||
* оставить в топбар чтобы подсвечивать новинки. туда же и сообщения".
|
||
* Docs + VersionBadge + Notifications. ThemeSwitch / Lang / Auth
|
||
* переехали в Sidebar footer. */}
|
||
<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>
|
||
<NotificationsBell />
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<button
|
||
type="button"
|
||
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||
>
|
||
<Bell size={14} strokeWidth={1.75} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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<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
|
||
|
||
// 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
|
||
}
|