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