7f3241686c
User clarification after MR !91: - "Search из TopBar убран — все верно" — TopBar search должен быть удалён. - "TopBar search вернулся. убери его" — MR !91 ошибочно его вернул. - "два крестика баг в топбаре был а ты и в таблице убрал его" — двойной X was в TopBar (native + custom); в catalog был только custom X на старом input. После refactor catalog input получился без X совсем — fix: добавил кастомный clear button когда q непустой. Changes: - TopBar: removed form + SearchInput + handlers (handleSearchInput / Submit / Clear / inputRef / useState). Removed useEffect ⌘K shortcut (без search input nothing to focus). Right cluster: Docs + VersionBadge + NotificationsBell. ml-auto на right cluster чтобы push'ить вправо без search в центре. - Catalog input: добавлен absolute-positioned X (✕) справа input'а когда `search.q` непустой. `pr-7` на input чтобы текст не наезжал. Clear → `setSearch({ q: undefined })`. Single X confirmed visually.
204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
import { useTranslation } from 'react-i18next'
|
||
import { Link, useLocation } from '@tanstack/react-router'
|
||
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||
import { Bell, Menu } from 'lucide-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 breadcrumb = useBreadcrumb()
|
||
|
||
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>
|
||
|
||
{/* TopBar search убран per user — catalog имеет свой input в toolbar,
|
||
* для records search используется /search route через sidebar nav.
|
||
* "Search из TopBar убран — все верно". */}
|
||
|
||
{/* 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
|
||
}
|