Merge branch 'feat/ui-polish-batch' into 'main'
feat(ui): batch UI polish — auth/lang to sidebar, search h-7, info overflow, graph zoom See merge request 2-6/2-6-4/terravault/ordinis!90
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, IconButton } from '@/ui'
|
||||
import { IconButton } from '@/ui'
|
||||
import { SignInIcon, SignOutIcon, UserIcon } from '@phosphor-icons/react'
|
||||
|
||||
/**
|
||||
@@ -24,27 +24,30 @@ export function AuthBadge() {
|
||||
if (auth.error) {
|
||||
// Не валим UI — просто кнопка для повтора login.
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
leftIcon={<SignInIcon size={16} />}
|
||||
onClick={() => auth.signinRedirect()}
|
||||
className="h-7 px-2.5 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 hover:text-ink inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<SignInIcon size={14} weight="regular" />
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
// Compact sign-in button matching TopBar control style (h-8, surface bg).
|
||||
// Previously Button variant="primary" — слишком крупно, выбивалось из
|
||||
// toolbar style per user feedback ("кнопка Войти слишком большая").
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="primary"
|
||||
leftIcon={<SignInIcon size={16} />}
|
||||
onClick={() => auth.signinRedirect()}
|
||||
className="h-7 px-2.5 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 hover:text-ink inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<SignInIcon size={14} weight="regular" />
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,18 +53,13 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Keycloak unreachable / OIDC discovery failed — показываем ошибку, но НЕ
|
||||
// блокируем UI (guest mode всё равно работает на read-api без JWT). Юзер
|
||||
// может видеть PUBLIC данные пока Keycloak вернётся в строй.
|
||||
// Keycloak unreachable / OIDC discovery failed — раньше показывали amber
|
||||
// banner вверху страницы, но per user feedback ("Авторизация недоступна:
|
||||
// Failed to fetch.?") это noise: anonymous mode работает без auth, error
|
||||
// banner лишь пугает. Тихо рендерим children в guest mode — AuthBadge
|
||||
// в TopBar показывает Sign in для retry.
|
||||
if (auth.error && !auth.isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 text-cell text-amber-900 text-center">
|
||||
Авторизация недоступна: {auth.error.message}. Доступен просмотр публичных данных.
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// ВАЖНО: НЕ блокируем UI пока {@code auth.isLoading=true}. Initial OIDC
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
Modal,
|
||||
Panel,
|
||||
SearchInput,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -48,7 +47,7 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
|
||||
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
import { recordDisplayName } from '@/lib/locales'
|
||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
||||
|
||||
/**
|
||||
* URL search params для filter state — share-friendly, persist между F5.
|
||||
@@ -224,18 +223,9 @@ function DictionaryDetail() {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleScope = (scope: DataScope) => {
|
||||
const next = new Set(scopeFilter)
|
||||
if (next.has(scope)) next.delete(scope)
|
||||
else next.add(scope)
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
// toggleScope удалён вместе с scope chips в records toolbar
|
||||
// (заменены на RecordColumnFilters status/operator dropdowns).
|
||||
// scopeFilter URL param остаётся read-only — backwards-compat shared links.
|
||||
|
||||
const handleAoiApply = (result: AoiResult) => {
|
||||
setAoi(result)
|
||||
@@ -258,6 +248,7 @@ function DictionaryDetail() {
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setColumnFilters({})
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
|
||||
replace: true,
|
||||
@@ -276,13 +267,25 @@ function DictionaryDetail() {
|
||||
return Array.from(set).sort((a, b) => a - b)
|
||||
}, [recordsResult.data])
|
||||
|
||||
// Column filters — per-column value match (e.g. status='operational',
|
||||
// operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
|
||||
// Replaces прошлый scope chips filter в records toolbar per user feedback.
|
||||
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({})
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const raw = recordsResult.data ?? []
|
||||
if (raw.length === 0) return raw
|
||||
const q = search.trim().toLowerCase()
|
||||
const scopes = scopeFilter
|
||||
const colKeys = Object.entries(columnFilters)
|
||||
return raw.filter((r) => {
|
||||
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
|
||||
// Column filters — exact match по data[field].
|
||||
for (const [field, value] of colKeys) {
|
||||
if (!value) continue
|
||||
const v = (r.data as Record<string, unknown>)[field]
|
||||
if (String(v ?? '') !== value) return false
|
||||
}
|
||||
if (q.length === 0) return true
|
||||
// Search по businessKey + JSON.stringify(data) — простой,
|
||||
// покрывает локализованные поля, code, описания. Stringify
|
||||
@@ -291,9 +294,12 @@ function DictionaryDetail() {
|
||||
const haystack = JSON.stringify(r.data).toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [recordsResult.data, search, scopeFilter])
|
||||
}, [recordsResult.data, search, scopeFilter, columnFilters])
|
||||
|
||||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||
const filtersActive =
|
||||
search.length > 0 ||
|
||||
scopeFilter.size > 0 ||
|
||||
Object.values(columnFilters).some(Boolean)
|
||||
const filteredCount = filteredRecords.length
|
||||
|
||||
// Client-side pagination — типичный словарь до 100 записей, server-side
|
||||
@@ -608,11 +614,19 @@ function DictionaryDetail() {
|
||||
[Sidebar (220px global)] [InfoPanel 220px] [main content]
|
||||
Info panel — sticky слева с dict metadata + relations.
|
||||
На <lg viewport schedule переключается в stacked (info above content). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
|
||||
{/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
|
||||
* gap-3 — closer panels per user feedback ("ближе расположены"). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3 mt-2">
|
||||
{detailQuery.data && (
|
||||
<EditorInfoPanel
|
||||
detail={detailQuery.data}
|
||||
recordCount={totalRecords}
|
||||
banner={
|
||||
// WorkflowBanner живёт в InfoPanel per user feedback ("плашки
|
||||
// пусть в левой панели будут") — освобождает horizontal space
|
||||
// editor'а и concentrates status info в одном месте.
|
||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||
}
|
||||
actions={{
|
||||
onTimeTravel: () => setTimeTravelOpen((v) => !v),
|
||||
timeTravelActive: Boolean(timeTravelAt),
|
||||
@@ -631,10 +645,8 @@ function DictionaryDetail() {
|
||||
)}
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
|
||||
{detailQuery.data && (
|
||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||
)}
|
||||
{/* WorkflowBanner перенесён в InfoPanel banner slot — освобождает
|
||||
* horizontal space здесь, concentrates все status notices в leftpanel. */}
|
||||
|
||||
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
|
||||
* per redesign prototype. Visual containment отделяет editor от
|
||||
@@ -794,8 +806,12 @@ function DictionaryDetail() {
|
||||
|
||||
{recordsResult.data && recordsResult.data.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[280px] max-w-md">
|
||||
{/* Records toolbar — attached к table panel (tinted top section,
|
||||
* border-b separator от table body). User feedback: "бар таблицы
|
||||
* не должен быть оторван от нее" — applied к catalog в MR 88,
|
||||
* теперь и в editor records tab. */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-surface-2 border-b border-line px-3 py-2">
|
||||
<div className="flex-1 min-w-[240px] max-w-md">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -803,31 +819,16 @@ function DictionaryDetail() {
|
||||
aria-label={t('dict.filter.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
|
||||
{SCOPE_ORDER.map((s) => {
|
||||
const active = scopeFilter.has(s)
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => toggleScope(s)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
|
||||
active
|
||||
? 'border-accent bg-accent/8 text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink/40',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(`dict.list.section.${s}`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Column filter dropdowns per redesign — auto-detect filterable
|
||||
* columns (status, enum-typed fields). User feedback: "фильтр в
|
||||
* справочнике по статусу скорее нужен чем по scope". Scope chips
|
||||
* убраны — scope viewable в SCOPE column, less useful as filter. */}
|
||||
<RecordColumnFilters
|
||||
schema={detailQuery.data?.schemaJson}
|
||||
records={recordsResult.data ?? []}
|
||||
value={columnFilters}
|
||||
onChange={setColumnFilters}
|
||||
/>
|
||||
{filtersActive && (
|
||||
<div className="flex items-center gap-2 text-cell text-ink-2">
|
||||
<span>
|
||||
@@ -856,7 +857,8 @@ function DictionaryDetail() {
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
)}
|
||||
<Panel>
|
||||
{/* No <Panel> wrap — outer editor panel handles border. Direct
|
||||
* table → continuous visual flow toolbar→table в one card. */}
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -976,7 +978,6 @@ function DictionaryDetail() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
{totalPages > 1 && (
|
||||
<RecordsPagination
|
||||
page={page}
|
||||
@@ -2032,3 +2033,111 @@ function HistoryTabContent({ detail }: { detail?: import('@/api/client').Diction
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RecordColumnFilters — auto-derive filterable columns из schema +
|
||||
* records data, render as compact select dropdowns "status ▾", "operator ▾".
|
||||
*
|
||||
* <p>Filterable columns детектятся по двум сигналам:
|
||||
* <ol>
|
||||
* <li>schema property имеет `enum` array → use enum values as options</li>
|
||||
* <li>иначе walk records, собирай уникальные значения для top non-i18n
|
||||
* string properties (status, operator, type). Если cardinality
|
||||
* (unique count) ≤ 12 — это reasonable filter; иначе skip (нужен search).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Replaces прошлый scope filter chips per user feedback ("фильтр в
|
||||
* справочнике по статусу скорее нужен чем по scope").
|
||||
*/
|
||||
function RecordColumnFilters({
|
||||
schema,
|
||||
records,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
schema?: import('@/api/client').JsonSchema
|
||||
records: FlattenedRecord[]
|
||||
value: Record<string, string>
|
||||
onChange: (v: Record<string, string>) => void
|
||||
}) {
|
||||
const filters = useMemo(() => {
|
||||
if (!schema?.properties) return []
|
||||
const out: { field: string; label: string; options: string[] }[] = []
|
||||
for (const [field, prop] of Object.entries(schema.properties)) {
|
||||
// Skip localized & FK fields — слишком много unique values.
|
||||
if (prop['x-localized']) continue
|
||||
// Enum-typed → use schema enum values.
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0 && prop.enum.length <= 20) {
|
||||
out.push({
|
||||
field,
|
||||
label: field,
|
||||
options: prop.enum.map((e) => String(e)).sort(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Skip типы где dropdown не имеет смысла:
|
||||
// - date/date-time/email/url: высокая cardinality, нужен picker/search
|
||||
// - numbers (integer/number): range filter полезнее dropdown
|
||||
if (
|
||||
prop.format === 'date' ||
|
||||
prop.format === 'date-time' ||
|
||||
prop.format === 'email' ||
|
||||
prop.format === 'url' ||
|
||||
prop.type === 'integer' ||
|
||||
prop.type === 'number' ||
|
||||
prop.type === 'boolean' ||
|
||||
prop.type === 'array' ||
|
||||
prop.type === 'object'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// FK (x-references) → use ALL unique values from records (target_dict
|
||||
// values typically have low cardinality at usage site).
|
||||
const isFk = typeof prop['x-references'] === 'string'
|
||||
// String-typed без enum но с low cardinality в records.
|
||||
if (prop.type === 'string' || isFk) {
|
||||
const seen = new Set<string>()
|
||||
for (const r of records) {
|
||||
const v = (r.data as Record<string, unknown>)[field]
|
||||
if (v !== null && v !== undefined && v !== '') {
|
||||
seen.add(String(v))
|
||||
if (seen.size > 12) break
|
||||
}
|
||||
}
|
||||
if (seen.size > 0 && seen.size <= 12) {
|
||||
out.push({
|
||||
field,
|
||||
label: field,
|
||||
options: Array.from(seen).sort(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.slice(0, 3) // Cap 3 filters max — не растянуть toolbar.
|
||||
}, [schema, records])
|
||||
|
||||
if (filters.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<select
|
||||
key={f.field}
|
||||
value={value[f.field] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, [f.field]: e.target.value })
|
||||
}
|
||||
className="h-8 px-2 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent cursor-pointer"
|
||||
aria-label={`Filter by ${f.label}`}
|
||||
>
|
||||
<option value="">{f.label} ▾</option>
|
||||
{f.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries'
|
||||
import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
@@ -174,15 +174,15 @@ function DictionariesPage() {
|
||||
// Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё
|
||||
// равно вернёт 401 на POST, но UI hide избегает confusing UX.
|
||||
const createButton = canMutate ? (
|
||||
<Button
|
||||
// Compact h-8 button — matches toolbar control style (Граф, scope pills).
|
||||
<button
|
||||
type="button"
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="whitespace-nowrap"
|
||||
className="h-8 px-3 rounded-md bg-navy text-on-accent text-cell font-semibold inline-flex items-center gap-1.5 hover:opacity-90 transition-opacity whitespace-nowrap"
|
||||
>
|
||||
<PlusIcon weight="bold" size={14} />
|
||||
{t('schema.action.create')}
|
||||
</Button>
|
||||
</button>
|
||||
) : null
|
||||
|
||||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
||||
@@ -221,12 +221,34 @@ function DictionariesPage() {
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Title block убран — "Справочники N шт." переехал в TopBar breadcrumb
|
||||
per user feedback ("эта надпись в топбаре"). Page начинается прямо с
|
||||
tinted toolbar bar, потом white table. */}
|
||||
per user feedback ("эта надпись в топбаре").
|
||||
Toolbar + table обёрнуты в ОДНУ rounded card с border, чтобы они
|
||||
визуально не были оторваны друг от друга (user: "чего бар таблицы
|
||||
то оторван от нее?"). Toolbar — surface-2 tinted top section,
|
||||
таблица — white body, разделены `border-b`. */}
|
||||
<div className="rounded-lg border border-line overflow-hidden bg-surface">
|
||||
|
||||
{/* Toolbar — top section of the panel, tinted bg + bottom border.
|
||||
* Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
|
||||
<div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5">
|
||||
{/* Catalog search — filter dicts by name/displayName/description. */}
|
||||
<div className="flex-1 min-w-[200px] max-w-xs">
|
||||
<input
|
||||
type="search"
|
||||
value={search.q ?? ''}
|
||||
onChange={(e) =>
|
||||
setSearch({ q: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t('dict.list.search.placeholder', {
|
||||
defaultValue: 'Поиск по справочникам…',
|
||||
})}
|
||||
aria-label={t('dict.list.search.placeholder', {
|
||||
defaultValue: 'Поиск по справочникам…',
|
||||
})}
|
||||
className="h-8 w-full px-2.5 rounded-md border border-line bg-surface text-cell text-ink placeholder:text-mute focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar in tinted bar per user feedback ("бар цветом").
|
||||
surface-2 (warm cream) визуально отделяет controls от table chrome. */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-surface-2 border border-line px-3 py-2">
|
||||
{/* Scope filter: PUBLIC / INTERNAL / RESTRICTED — pill chips с border */}
|
||||
<div
|
||||
role="group"
|
||||
@@ -242,10 +264,13 @@ function DictionariesPage() {
|
||||
onClick={() => toggleScope(scope)}
|
||||
aria-pressed={selected}
|
||||
title={t(`dict.list.section.${scope}`)}
|
||||
className={`text-cap tracking-[0.14em] uppercase font-semibold px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
// Scope chips всегда покрашены в свой scope-color (filled bg
|
||||
// + colored text) per user feedback ("плашки то покрась").
|
||||
// Selected state — ring-1 ring-{scope} + slightly opaque bg.
|
||||
className={`text-cap tracking-[0.14em] uppercase font-semibold px-2.5 py-1 rounded-md border transition-all focus:outline-none focus:ring-2 focus:ring-accent/40 ${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} ${
|
||||
selected
|
||||
? `${SCOPE_BG_TINT[scope]} ${SCOPE_TEXT[scope]} border-transparent`
|
||||
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||
? `border-current shadow-sm`
|
||||
: 'border-transparent opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
@@ -267,7 +292,7 @@ function DictionariesPage() {
|
||||
type="button"
|
||||
onClick={() => setBundle(undefined)}
|
||||
aria-pressed={!bundleFilter}
|
||||
className={`text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
className={`text-cell px-2.5 py-1 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
!bundleFilter
|
||||
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||||
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||
@@ -285,7 +310,7 @@ function DictionariesPage() {
|
||||
type="button"
|
||||
onClick={() => setBundle(selected ? undefined : bundle)}
|
||||
aria-pressed={selected}
|
||||
className={`text-mono text-body px-3 py-1.5 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
className={`text-mono text-cell px-2.5 py-1 rounded-md border transition-colors focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
selected
|
||||
? 'border-ink bg-surface text-ink ring-1 ring-ink font-medium'
|
||||
: 'border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2'
|
||||
@@ -308,7 +333,7 @@ function DictionariesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/graph' })}
|
||||
className="h-9 px-3 rounded-md border border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2 text-body inline-flex items-center gap-1.5 transition-colors"
|
||||
className="h-8 px-2.5 rounded-md border border-line bg-surface text-ink-2 hover:border-ink-2 hover:bg-surface-2 text-cell inline-flex items-center gap-1.5 transition-colors"
|
||||
title={t('dict.list.graph', { defaultValue: 'Граф связей' })}
|
||||
>
|
||||
{t('dict.list.graph', { defaultValue: 'Граф' })}
|
||||
@@ -320,9 +345,9 @@ function DictionariesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{/* Empty state OR table — body of the panel (toolbar + body = one card). */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="border border-line rounded-lg p-12 text-center bg-surface-2">
|
||||
<div className="p-12 text-center bg-surface">
|
||||
<p className="font-sans text-title-md text-accent mb-2">
|
||||
{t('dict.list.search.empty')}
|
||||
</p>
|
||||
@@ -339,6 +364,9 @@ function DictionariesPage() {
|
||||
<DictionaryListTable rows={filtered} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
{/* === end catalog panel (toolbar + table) === */}
|
||||
|
||||
<DictionaryEditorDialog
|
||||
open={createOpen}
|
||||
mode={{ kind: 'create' }}
|
||||
@@ -380,27 +408,81 @@ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
|
||||
* Columns: name+subtitle / id / bundle / scope / records / → / ← / updated.
|
||||
* Click row → navigate к editor.
|
||||
*/
|
||||
type SortKey = 'title' | 'id' | 'bundle' | 'scope' | 'updated'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
|
||||
const { t } = useTranslation()
|
||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({
|
||||
key: 'title',
|
||||
dir: 'asc',
|
||||
})
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const arr = [...rows]
|
||||
arr.sort((a, b) => {
|
||||
let av: string | number = ''
|
||||
let bv: string | number = ''
|
||||
switch (sort.key) {
|
||||
case 'title':
|
||||
av = (a.displayName ?? a.name).toLowerCase()
|
||||
bv = (b.displayName ?? b.name).toLowerCase()
|
||||
break
|
||||
case 'id':
|
||||
av = a.name.toLowerCase()
|
||||
bv = b.name.toLowerCase()
|
||||
break
|
||||
case 'bundle':
|
||||
av = a.bundle.toLowerCase()
|
||||
bv = b.bundle.toLowerCase()
|
||||
break
|
||||
case 'scope': {
|
||||
const order = { PUBLIC: 0, INTERNAL: 1, RESTRICTED: 2 }
|
||||
av = order[a.scope]
|
||||
bv = order[b.scope]
|
||||
break
|
||||
}
|
||||
case 'updated':
|
||||
av = a.updatedAt
|
||||
bv = b.updatedAt
|
||||
break
|
||||
}
|
||||
if (av < bv) return sort.dir === 'asc' ? -1 : 1
|
||||
if (av > bv) return sort.dir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
return arr
|
||||
}, [rows, sort])
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
setSort((s) =>
|
||||
s.key === key
|
||||
? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key, dir: 'asc' },
|
||||
)
|
||||
}
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sort.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line overflow-hidden bg-surface">
|
||||
<table className="w-full">
|
||||
{/* White header per user feedback ("заголовки таблицы белый фон").
|
||||
Прошлое bg-surface-2 сливалось с toolbar bar. */}
|
||||
// No outer rounded/border wrapper — outer panel handles that (toolbar + table
|
||||
// в одной card). Just table with white header per user feedback.
|
||||
<table className="w-full bg-surface">
|
||||
<thead className="bg-surface border-b border-line">
|
||||
<tr>
|
||||
<th scope="col" className="text-cap text-mute text-left px-3 py-2">
|
||||
{t('dict.col.title', { defaultValue: 'Название' })}
|
||||
</th>
|
||||
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden md:table-cell">
|
||||
{t('dict.col.id', { defaultValue: 'id' })}
|
||||
</th>
|
||||
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden lg:table-cell">
|
||||
{t('dict.col.bundle', { defaultValue: 'bundle' })}
|
||||
</th>
|
||||
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden sm:table-cell">
|
||||
{t('dict.col.scope', { defaultValue: 'scope' })}
|
||||
</th>
|
||||
<SortableHeader onClick={() => toggleSort('title')} active={sort.key === 'title'}>
|
||||
{t('dict.col.title', { defaultValue: 'Название' })}{arrow('title')}
|
||||
</SortableHeader>
|
||||
<SortableHeader hideBelow="md" onClick={() => toggleSort('id')} active={sort.key === 'id'}>
|
||||
{t('dict.col.id', { defaultValue: 'id' })}{arrow('id')}
|
||||
</SortableHeader>
|
||||
<SortableHeader hideBelow="lg" onClick={() => toggleSort('bundle')} active={sort.key === 'bundle'}>
|
||||
{t('dict.col.bundle', { defaultValue: 'bundle' })}{arrow('bundle')}
|
||||
</SortableHeader>
|
||||
<SortableHeader hideBelow="sm" onClick={() => toggleSort('scope')} active={sort.key === 'scope'}>
|
||||
{t('dict.col.scope', { defaultValue: 'scope' })}{arrow('scope')}
|
||||
</SortableHeader>
|
||||
<th scope="col" className="text-cap text-mute text-right px-3 py-2 hidden md:table-cell">
|
||||
{t('dict.col.records', { defaultValue: 'записей' })}
|
||||
</th>
|
||||
@@ -418,20 +500,48 @@ function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
|
||||
>
|
||||
←
|
||||
</th>
|
||||
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden xl:table-cell">
|
||||
{t('dict.col.updated', { defaultValue: 'изменён' })}
|
||||
</th>
|
||||
<SortableHeader hideBelow="xl" onClick={() => toggleSort('updated')} active={sort.key === 'updated'}>
|
||||
{t('dict.col.updated', { defaultValue: 'изменён' })}{arrow('updated')}
|
||||
</SortableHeader>
|
||||
{/* Chevron col — affordance что row кликабелен (открывает editor) */}
|
||||
<th scope="col" aria-hidden className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
{sortedRows.map((d) => (
|
||||
<DictRow key={d.id} d={d} t={t} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SortableHeader — th cell с click-to-sort affordance. Active column текст
|
||||
* ink (вместо mute), hover dimming.
|
||||
*/
|
||||
function SortableHeader({
|
||||
children,
|
||||
onClick,
|
||||
active,
|
||||
hideBelow,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
active: boolean
|
||||
hideBelow?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}) {
|
||||
const hideClass = hideBelow
|
||||
? { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell', xl: 'hidden xl:table-cell' }[hideBelow]
|
||||
: ''
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={`text-cap text-left px-3 py-2 cursor-pointer select-none transition-colors ${active ? 'text-ink' : 'text-mute hover:text-ink'} ${hideClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -441,6 +551,18 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
|
||||
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
|
||||
const incomingCount = refBy.length
|
||||
|
||||
// ЗАПИСЕЙ count — backend dictionaries list endpoint не возвращает
|
||||
// recordCount, fetch records list per row. TanStack Query deduplicate'ит
|
||||
// конкурентные запросы. Show `?` пока loading, иначе length.
|
||||
// TODO когда backend добавит recordCount в list response — fallback на него.
|
||||
const recordsQuery = useRecords(d.name, 'PUBLIC,INTERNAL,RESTRICTED')
|
||||
const recordCount =
|
||||
typeof d.recordCount === 'number'
|
||||
? d.recordCount
|
||||
: recordsQuery.data
|
||||
? recordsQuery.data.length
|
||||
: undefined
|
||||
|
||||
// Relative time per redesign prototype: 12мин / 2ч / 3д / 1мес.
|
||||
// Stale absolute date (YYYY-MM-DD) скрывал scale (5 минут vs 3 месяца —
|
||||
// в формате 05/06/2026 это одинаково "далёкая дата").
|
||||
@@ -522,9 +644,9 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
|
||||
{d.scope}
|
||||
</span>
|
||||
</td>
|
||||
{/* records count */}
|
||||
{/* records count — fetched per-row via useRecords (cached). */}
|
||||
<td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">
|
||||
{typeof d.recordCount === 'number' ? d.recordCount : '—'}
|
||||
{recordCount !== undefined ? recordCount : '…'}
|
||||
</td>
|
||||
{/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
|
||||
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">—</td>
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
beforeLoad: ({ location }) => {
|
||||
// OIDC callback guard: когда Keycloak возвращает на `/?code=...&state=...`,
|
||||
// AuthProvider (oidc-client-ts) обрабатывает params в useEffect и затем
|
||||
// вызывает `onSigninCallback` который стрипает их через history.replaceState.
|
||||
// Если router сразу redirect'нет на /dictionaries — code/state пропадут до
|
||||
// того как AuthProvider их обработает → token exchange fail, user остаётся
|
||||
// anonymous. Поэтому skip redirect когда есть ?code= в URL — компонент
|
||||
// ниже сам redirect'нет после auth complete.
|
||||
const raw = location.search as Record<string, unknown>
|
||||
if (raw && typeof raw.code === 'string') {
|
||||
return
|
||||
}
|
||||
throw redirect({ to: '/dictionaries' })
|
||||
},
|
||||
component: HomeIndex,
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders only during OIDC callback (`/?code=...&state=...` от Keycloak).
|
||||
* Waits for auth.isLoading=false → onSigninCallback вызвался → URL stripped
|
||||
* → navigate to /dictionaries.
|
||||
*/
|
||||
function HomeIndex() {
|
||||
const auth = useAuth()
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (!auth.isLoading) {
|
||||
void navigate({ to: '/dictionaries', replace: true })
|
||||
}
|
||||
}, [auth.isLoading, navigate])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ export function LanguageSwitch({
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? 'Language'}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border border-line bg-surface p-0.5 gap-0.5',
|
||||
// h-8 box to match TopBar toolbar height. Buttons fill 100% height
|
||||
// через inline-flex + items-center — никакого vertical jitter
|
||||
// от text baseline vs icon baseline.
|
||||
'inline-flex items-stretch rounded-md border border-line bg-surface h-7 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -55,7 +58,7 @@ export function LanguageSwitch({
|
||||
onClick={() => onChange(v)}
|
||||
title={opt.label}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-sm text-cap leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'inline-flex items-center justify-center px-2.5 text-cap leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active
|
||||
? 'bg-accent-bg text-accent'
|
||||
: 'text-mute hover:text-ink hover:bg-surface-2',
|
||||
|
||||
Reference in New Issue
Block a user