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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user