feat(ui): batch UI polish — auth/lang to sidebar, search h-7, info overflow, graph zoom

User feedback batch — multiple iterations of UX refinement.

Layout shifts:
- TopBar right cluster: AuthBadge + LanguageSwitch переехали в Sidebar
  footer per user ("вход в сайдбар перенесем", "ru/en тоже в сайд баре").
  TopBar теперь: breadcrumb + search + Docs + VersionBadge + ThemeSwitch
  only. Освобождает место + concentrates user/lang controls в одном месте.
- Sidebar footer: новая структура — Lang toggle (Язык [RU/EN]) + Auth block
  (avatar+name+logout authed, Sign-in CTA anonymous) + collapse button.
  Refactored из inline JSX в named subcomponents (AuthedUserBlock,
  SignInCta, SidebarFooter).

Sizing / spacing:
- TopBar SearchInput: h-9 → h-7 to match LangSwitch/ThemeSwitch (+ Sign-in
  кнопка). User: "поиск по справочникам высоту выровняй". Override via
  `!h-7 text-cell` className на SearchInput.
- TopBar: h-14 → h-11 (slimmer). Sidebar logo block matches h-11.

Catalog search:
- Catalog page input в toolbar — restored для on-page filter ("Catalog
  search никак не работает" → проверил, работает; добавил TopBar context-
  aware behavior где TopBar input на /dictionaries route синхронизируется
  с URL ?q= live).
- TopBar search context-aware: catalog mode = live filter; else = submit
  → /search (records JSONB search).

InfoPanel:
- Description: `text-body text-ink-2 leading-relaxed` → +
  `break-words overflow-hidden`. Long slash-separated values
  (LZW/Deflate/JPEG2000/ZSTD/LERC) теперь wrap'ятся внутри panel вместо
  overflow за границы (user: "описание убежало за границы панели").
- Container: + `min-w-0 overflow-hidden` для proper flex shrinking.

Graph:
- Zoom controls overlay (+/-/1:1/N%) + mouse wheel zoom toward cursor +
  drag pan по empty space. Per user: "Граф связей еще не зумируется".

WorkflowBanner:
- Moved в InfoPanel banner slot (compact flex-col layout) — free
  horizontal space выше editor + concentrates status info в left rail.

Auth:
- AuthBadge: primary Button → compact h-7 outline button. Matches
  TopBar toolbar control style.
- RequireAuth: убран visible amber error banner "Авторизация недоступна:
  Failed to fetch" — silent fall-through к anonymous mode (user feedback).
- routes/index.tsx: beforeLoad skip redirect если есть `?code=` в URL
  (OIDC callback fix). HomeIndex компонент рендерит null + redirect на
  /dictionaries после auth complete.

LanguageSwitch:
- h-8 + items-stretch + inner items-center — matches ThemeSwitch height.
- Later moved entirely в Sidebar footer (h-7 button only) per user.
This commit is contained in:
Zimin A.N.
2026-05-11 21:50:21 +03:00
parent b0e19951b3
commit d9df2fc359
12 changed files with 685 additions and 195 deletions
@@ -1,19 +1,12 @@
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
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 { Menu } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const LANG_OPTIONS = [
{ id: 'ru-RU', label: 'RU' },
{ id: 'en-US', label: 'EN' },
]
/**
* TopBar — sticky header (h=56) per handoff design.
*
@@ -24,11 +17,22 @@ const LANG_OPTIONS = [
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
*/
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const navigate = useNavigate()
const [searchValue, setSearchValue] = useState('')
const location = useLocation()
const isCatalog = location.pathname === '/dictionaries'
// Catalog mode: bind input to URL ?q= (live filter). Else: local state submitting к /search.
const catalogQ = isCatalog
? ((location.search as Record<string, unknown>)?.q as string | undefined) ?? ''
: ''
const [searchValue, setSearchValue] = useState(catalogQ)
const inputRef = useRef<HTMLInputElement>(null)
// Sync local state с URL когда navigate на /dictionaries (catalog).
useEffect(() => {
setSearchValue(catalogQ)
}, [catalogQ])
const breadcrumb = useBreadcrumb()
// ⌘K / Ctrl+K shortcut — focus search input from anywhere. Skip когда
@@ -50,17 +54,50 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
return () => window.removeEventListener('keydown', handler)
}, [])
// Context-aware search:
// - На /dictionaries (catalog) каждый keystroke обновляет URL `?q=` чтобы
// filter работал live. Submit (Enter) — no-op (фильтр уже applied).
// - На остальных routes input — local state, submit redirect'ит на /search
// с records 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
// Navigate даже если q < 3 — search route сам покажет "min 3 chars" hint.
// Раньше блокировал в TopBar → юзер думал «поиск не работает».
if (isCatalog) return // На catalog уже live filter, submit не нужен.
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-14 shrink-0 border-b border-line bg-surface flex items-center px-4 sm:px-6 gap-3">
<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
@@ -118,18 +155,22 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
<SearchInput
ref={inputRef}
placeholder={t('topbar.search.placeholder')}
placeholder={
isCatalog
? t('topbar.search.catalog', { defaultValue: 'Поиск по справочникам, ⌘K' })
: t('topbar.search.placeholder')
}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onClear={() => setSearchValue('')}
onChange={handleSearchInput}
onClear={handleSearchClear}
aria-label={t('topbar.search.label')}
className="!h-7 text-cell"
/>
</form>
{/* Right cluster — user prefers full set: Docs link + VersionBadge +
* ThemeSwitch + LanguageSwitch + AuthBadge (logged-in avatar/sign-in).
* User explicitly: "Руководство · dev · {commit} · theme · RU EN ·
* zimin.an · logout это оставь". */}
{/* Right cluster — Docs + VersionBadge + ThemeSwitch only.
* AuthBadge + LanguageSwitch переехали в Sidebar footer per user
* feedback ("вход в сайдбар перенесем", "ru/en в сайдбаре"). */}
<div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0">
<a
href="/docs/"
@@ -143,12 +184,6 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
<VersionBadge />
</span>
<ThemeSwitch />
<LanguageSwitch
value={i18n.language}
options={LANG_OPTIONS}
onChange={(id) => i18n.changeLanguage(id)}
/>
<AuthBadge />
</div>
</header>
)