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
@@ -41,9 +41,13 @@ export type EditorInfoPanelProps = {
onExport?: () => void
exportPending?: boolean
}
/** Optional banner slot — WorkflowBanner / status notices живут в
* left panel per user feedback ("плашки пусть в левой панели будут").
* Renders at top of panel before scope chip. */
banner?: React.ReactNode
}
export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPanelProps) {
export function EditorInfoPanel({ detail, recordCount, actions, banner }: EditorInfoPanelProps) {
const { t } = useTranslation()
const { data: refByRaw } = useDictionaryDependents(detail.name)
@@ -51,7 +55,13 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
const incomingDeps: SchemaDependent[] = useMemo(() => refByRaw ?? [], [refByRaw])
return (
<aside className="lg:sticky lg:top-2 self-start space-y-4 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto rounded-lg border border-line bg-surface p-4">
// Flex-col panel — stretches to full grid row height (no self-start).
// Actions block при наличии actions получает `mt-auto` чтобы прижаться к
// нижнему краю panel — per user feedback "всегда одной высоты".
<aside className="flex flex-col space-y-4 rounded-lg border border-line bg-surface p-4 min-h-full min-w-0 overflow-hidden">
{/* Banner slot — WorkflowBanner status notice (if any). */}
{banner}
{/* Scope badge — top of panel */}
<span
className={cn(
@@ -62,10 +72,14 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
{detail.scope}
</span>
{/* Description */}
{/* Description — break-words нужен потому что описания могут быть
* без spaces (slash-separated lists типа "LZW/Deflate/JPEG2000") и
* вылезают за границы narrow panel'а. `break-words` breaks при
* необходимости anywhere; `overflow-hidden` clip'нет если что-то
* unbreakable. */}
{detail.description && (
<Section label={t('editor.info.description', { defaultValue: 'Описание' })}>
<p className="text-body text-ink-2 leading-relaxed">{detail.description}</p>
<p className="text-body text-ink-2 leading-relaxed break-words overflow-hidden">{detail.description}</p>
</Section>
)}
@@ -171,7 +185,7 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
* + AOI. После Relations block чтобы не разрывать info → meta → relations
* мыслительный поток info-panel'а. */}
{actions && (
<div className="space-y-2 pt-3 mt-2 border-t border-line-2">
<div className="space-y-2 pt-3 mt-auto border-t border-line-2">
{actions.onExport && (
<button
type="button"
@@ -107,15 +107,19 @@ function Banner({ variant, icon, action, children }: BannerProps) {
<div
role="status"
className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-md border-l-4 text-body',
// Compact layout — banner живёт в InfoPanel (narrow 220px column),
// нужен vertical-ish stack а не широкий horizontal banner.
'flex flex-col gap-1.5 px-3 py-2 rounded-md border-l-4 text-body',
VARIANT_CLASS[variant],
)}
>
{icon}
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
{children}
<div className="flex items-center gap-2 min-w-0">
{icon}
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
{children}
</div>
</div>
{action}
{action && <div className="pl-6">{action}</div>}
</div>
)
}
@@ -119,6 +119,10 @@ function GraphCanvas({
const svgRef = useRef<SVGSVGElement>(null)
const [size, setSize] = useState({ width: 800, height: 600 })
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Zoom/pan state — управляется wheel events + mouse drag на empty space.
// Transform применяется к <g> wrapper'у, эффективно scaling всех children.
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null)
// Force-rerender tick. Simulation .on('tick') увеличивает это значение,
// React видит state change → перерисовывает SVG с новыми node.x/y. Не
// читаем value напрямую, только инкрементируем — eslint complains, поэтому
@@ -226,12 +230,100 @@ function GraphCanvas({
Загрузка связей
</div>
)}
{/* Zoom controls — overlay top-right. */}
<div className="absolute top-2 right-2 z-10 inline-flex items-center gap-1 rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<button
type="button"
onClick={() =>
setView((v) => ({ ...v, zoom: Math.min(4, v.zoom * 1.25) }))
}
aria-label="Zoom in"
title="Zoom in (+)"
className="size-7 inline-flex items-center justify-center text-ink-2 hover:text-ink hover:bg-surface-2 rounded-sm transition-colors"
>
+
</button>
<button
type="button"
onClick={() =>
setView((v) => ({ ...v, zoom: Math.max(0.2, v.zoom / 1.25) }))
}
aria-label="Zoom out"
title="Zoom out ()"
className="size-7 inline-flex items-center justify-center text-ink-2 hover:text-ink hover:bg-surface-2 rounded-sm transition-colors"
>
</button>
<button
type="button"
onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
aria-label="Reset view"
title="Reset (1:1)"
className="h-7 px-2 inline-flex items-center justify-center text-cap text-mute hover:text-ink hover:bg-surface-2 rounded-sm transition-colors"
>
1:1
</button>
<span className="px-1 text-mono text-cap text-mute tabular-nums">
{Math.round(view.zoom * 100)}%
</span>
</div>
<svg
ref={svgRef}
width={size.width}
height={size.height}
viewBox={`0 0 ${size.width} ${size.height}`}
className="block touch-none"
style={{ cursor: panStartRef.current ? 'grabbing' : 'grab' }}
onWheel={(e) => {
// Zoom toward cursor — scale factor based on wheel delta.
// Negative deltaY (scroll up) = zoom in.
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
setView((v) => {
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
const newZoom = Math.min(4, Math.max(0.2, v.zoom * factor))
// Anchor zoom вокруг cursor: viewport position под cursor должна
// остаться той же относительно world coordinates.
const worldX = (mx - v.panX) / v.zoom
const worldY = (my - v.panY) / v.zoom
return {
zoom: newZoom,
panX: mx - worldX * newZoom,
panY: my - worldY * newZoom,
}
})
}}
onMouseDown={(e) => {
// Pan только если клик НЕ на ноде. Узлы свои onMouseDown handlers.
if ((e.target as SVGElement).tagName === 'svg') {
panStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: view.panX,
panY: view.panY,
}
}
}}
onMouseMove={(e) => {
if (panStartRef.current) {
const dx = e.clientX - panStartRef.current.x
const dy = e.clientY - panStartRef.current.y
setView((v) => ({
...v,
panX: panStartRef.current!.panX + dx,
panY: panStartRef.current!.panY + dy,
}))
}
}}
onMouseUp={() => {
panStartRef.current = null
}}
onMouseLeave={() => {
panStartRef.current = null
}}
>
<defs>
<marker
@@ -258,6 +350,8 @@ function GraphCanvas({
</marker>
</defs>
{/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */}
<g transform={`translate(${view.panX} ${view.panY}) scale(${view.zoom})`}>
{/* Edges (под nodes) */}
{linksRef.current.map((l, i) => {
const s = l.source as GraphNode
@@ -316,6 +410,8 @@ function GraphCanvas({
</g>
)
})}
</g>
{/* === end zoom/pan transform === */}
</svg>
{/* Legend */}
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-surface/90 backdrop-blur px-3 py-2 rounded-md border border-line text-cell">
@@ -159,13 +159,12 @@ function SidebarContent({
/** Toggle button handler. When undefined — collapse button hidden (mobile). */
onToggleCollapsed?: () => void
}) {
const { t } = useTranslation()
return (
<>
{/* Logo block per redesign prototype: circle (accent) + ORDINIS + MDM mute */}
<div
className={cn(
'h-14 flex items-center border-b border-line shrink-0',
'h-11 flex items-center border-b border-line shrink-0',
collapsed ? 'justify-center px-2' : 'px-5 gap-2',
)}
>
@@ -213,48 +212,57 @@ function SidebarContent({
))}
</nav>
{/* User block — sticky bottom */}
{auth.isAuthenticated && auth.user?.profile && (
<div
className={cn(
'border-t border-line py-3 flex items-center gap-2 shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
)}
>
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
'U',
)
.charAt(0)
.toUpperCase()}
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
)}
</div>
{auth.user.profile.email && (
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</div>
)}
</div>
)}
{/* Footer: Lang toggle + User profile (or Sign-in) + Collapse toggle.
* Moved from TopBar per user feedback: "вход в сайдбар, ru/en в сайдбаре". */}
<SidebarFooter
auth={auth}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
</>
)
}
function SidebarFooter({
auth,
collapsed,
onToggleCollapsed,
}: {
auth: ReturnType<typeof useAuth>
collapsed: boolean
onToggleCollapsed?: () => void
}) {
const { t, i18n } = useTranslation()
const cur = i18n.language?.toLowerCase().startsWith('en') ? 'en' : 'ru'
const next = cur === 'ru' ? 'en-US' : 'ru-RU'
return (
<>
{/* Language toggle row (только когда expanded). */}
{!collapsed && (
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
<span className="text-cap text-mute">
{t('sidebar.lang', { defaultValue: 'Язык' })}
</span>
<button
type="button"
onClick={() => i18n.changeLanguage(next)}
className="ml-auto h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
>
{cur.toUpperCase()}
</button>
</div>
)}
{/* Collapse toggle — desktop only (mobile uses Sheet close ×) */}
{/* Auth block: avatar + name (authed) OR Sign-in CTA (anonymous). */}
{auth.isAuthenticated && auth.user?.profile ? (
<AuthedUserBlock auth={auth} collapsed={collapsed} />
) : (
<SignInCta auth={auth} collapsed={collapsed} />
)}
{/* Collapse toggle */}
{onToggleCollapsed && (
<button
type="button"
@@ -279,6 +287,77 @@ function SidebarContent({
)
}
function AuthedUserBlock({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
collapsed: boolean
}) {
const { t } = useTranslation()
const profile = auth.user?.profile
if (!profile) return null
const display = String(
profile.preferred_username || profile.name || profile.email || 'user',
)
return (
<div
className={cn(
'border-t border-line py-3 flex items-center gap-2 shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={display}
>
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
{display.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">{display}</div>
{profile.email && (
<div className="text-cell text-mute truncate">{String(profile.email)}</div>
)}
</div>
)}
{!collapsed && (
<button
type="button"
onClick={() => auth.signoutRedirect().catch(() => auth.removeUser())}
title={t('auth.logout', { defaultValue: 'Logout' })}
aria-label={t('auth.logout', { defaultValue: 'Logout' })}
className="size-7 rounded-md text-mute hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors shrink-0"
>
<span aria-hidden></span>
</button>
)}
</div>
)
}
function SignInCta({
auth,
collapsed,
}: {
auth: ReturnType<typeof useAuth>
collapsed: boolean
}) {
const { t } = useTranslation()
return (
<button
type="button"
onClick={() => auth.signinRedirect()}
className={cn(
'border-t border-line py-2.5 text-cell text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center gap-2 transition-colors shrink-0',
collapsed ? 'px-2 justify-center' : 'px-3',
)}
title={t('auth.login', { defaultValue: 'Войти' })}
>
<span aria-hidden></span>
{!collapsed && t('auth.login', { defaultValue: 'Войти' })}
</button>
)
}
/**
* MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается
* через hamburger button в TopBar (<1024px), закрывается на: backdrop click,
@@ -27,7 +27,7 @@ export function ThemeSwitch() {
<div
role="radiogroup"
aria-label={t('theme.label')}
className="inline-flex items-center rounded-md border border-line bg-surface p-0.5"
className="inline-flex items-center rounded-md border border-line bg-surface h-7 overflow-hidden"
>
{items.map((item) => {
const Icon = item.icon
@@ -42,7 +42,7 @@ export function ThemeSwitch() {
title={item.label}
onClick={() => setPreference(item.id)}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded transition-colors',
'inline-flex h-full w-7 items-center justify-center transition-colors',
active
? 'bg-accent text-on-accent'
: 'text-mute hover:text-ink hover:bg-surface-2',
@@ -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>
)