Files
mdm-ordinis/ordinis-admin-ui/src/components/layout/TopBar.tsx
T
Zimin A.N. 7e44df3493 fix(ui): restore TopBar search + add notifications bell + theme to sidebar
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 появится.
2026-05-11 22:03:12 +03:00

291 lines
10 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 { 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
}