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
@@ -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>
+32 -2
View File
@@ -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
}