feat(editor): match redesign prototype layout — TopBar title + tab counts + InfoPanel actions
Per /Users/zimin/Downloads/redesign/screens/03-editor-records.png редизайн
prototype editor layout отличается от current MVP:
1. TopBar absorbs editor title + version (was в PageHeader)
2. PageHeader удалён — title и breadcrumb теперь только в TopBar
3. Tab labels получили counts (Записи 127, Связи 6, Поля 12)
4. Action buttons распределены per role:
- Time-travel + AOI → InfoPanel bottom (close к data context)
- Schema edit + +Запись → tab toolbar right (close к tab actions)
Changes:
src/components/layout/TopBar.tsx
- Breadcrumb получил subtitle slot — mono '<name> · v<version>'
- useBreadcrumb fetches dict detail для editor route '/dictionaries/$name'
- Render: '← Справочники / Космические аппараты satellites · v1.0.0'
src/api/queries.ts
- useDictionaryDetail принимает name: string | undefined + enabled flag
- Guard для editor breadcrumb когда route не matches dict
src/routes/dictionaries.$name.tsx
- Removed <PageHeader> — title/breadcrumb теперь в TopBar
- EditorInfoPanel получает actions prop:
onTimeTravel + timeTravelActive + onAoi + aoiActive
- EditorTabBar получает counts (per-tab badge) + actions slot
(right-aligned Schema + +Запись buttons)
- extractOutgoingFkCount helper для relations count
src/components/editor/EditorInfoPanel.tsx
- actions prop с Time-travel + AOI buttons
- Rendered в panel выше Relations section, между metadata и FK list
- aria-pressed когда active (time-travel или AOI с filter)
src/components/editor/EditorTabBar (inline in route)
- counts prop: Partial<Record<EditorTab, number>>
- actions prop: ReactNode для right-aligned cluster
- inline count badge — text-mono text-mute next to label
Tests: 116 pass. TS strict clean.
This commit is contained in:
@@ -502,7 +502,8 @@ export const useLiveRecord = (dict: string | undefined, businessKey: string | un
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string | undefined) =>
|
||||||
|
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
scopeCsv: string,
|
scopeCsv: string,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
|
||||||
import { Badge } from '@/ui'
|
import { Badge } from '@/ui'
|
||||||
import { useDictionaryDependents } from '@/api/queries'
|
import { useDictionaryDependents } from '@/api/queries'
|
||||||
import type { DictionaryDetail } from '@/api/client'
|
import type { DictionaryDetail } from '@/api/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EditorInfoPanel — left rail с dict metadata per handoff prototype Screen 2.
|
* EditorInfoPanel — left rail с dict metadata per handoff prototype Screen 2.
|
||||||
@@ -23,9 +25,17 @@ import type { DictionaryDetail } from '@/api/client'
|
|||||||
export type EditorInfoPanelProps = {
|
export type EditorInfoPanelProps = {
|
||||||
detail: DictionaryDetail
|
detail: DictionaryDetail
|
||||||
recordCount: number
|
recordCount: number
|
||||||
|
/** Optional action buttons (Time-travel / AOI filter) — bottom of panel
|
||||||
|
* per redesign prototype. Toggle handlers come from route's state. */
|
||||||
|
actions?: {
|
||||||
|
onTimeTravel?: () => void
|
||||||
|
timeTravelActive?: boolean
|
||||||
|
onAoi?: () => void
|
||||||
|
aoiActive?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
|
export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPanelProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
||||||
|
|
||||||
@@ -64,6 +74,44 @@ export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
|
|||||||
</MetaCell>
|
</MetaCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — bottom buttons per redesign prototype: Time-travel + AOI */}
|
||||||
|
{actions && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-line-2">
|
||||||
|
{actions.onTimeTravel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={actions.onTimeTravel}
|
||||||
|
aria-pressed={actions.timeTravelActive}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
||||||
|
actions.timeTravelActive
|
||||||
|
? 'border-accent bg-accent-bg text-accent'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClockCounterClockwiseIcon weight="regular" size={14} />
|
||||||
|
{t('timeTravel.button', { defaultValue: 'Time-travel…' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actions.onAoi && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={actions.onAoi}
|
||||||
|
aria-pressed={actions.aoiActive}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
||||||
|
actions.aoiActive
|
||||||
|
? 'border-accent bg-accent-bg text-accent'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MapTrifoldIcon weight="regular" size={14} />
|
||||||
|
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Relations */}
|
{/* Relations */}
|
||||||
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
|
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
|
import { Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useDictionaryDetail } from '@/api/queries'
|
||||||
import { LanguageSwitch } from '@/ui'
|
import { LanguageSwitch } from '@/ui'
|
||||||
import { AuthBadge } from '@/auth/AuthBadge'
|
import { AuthBadge } from '@/auth/AuthBadge'
|
||||||
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
||||||
@@ -72,7 +73,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Breadcrumb — left */}
|
{/* Breadcrumb — left. Editor route ('/dictionaries/$name') получает
|
||||||
|
* augmented breadcrumb с displayName + version (per redesign prototype).
|
||||||
|
* Остальные routes: regular path-based breadcrumb. */}
|
||||||
<nav className="flex items-center gap-1.5 text-body min-w-0">
|
<nav className="flex items-center gap-1.5 text-body min-w-0">
|
||||||
{breadcrumb.map((crumb, i) => {
|
{breadcrumb.map((crumb, i) => {
|
||||||
const isLast = i === breadcrumb.length - 1
|
const isLast = i === breadcrumb.length - 1
|
||||||
@@ -91,6 +94,10 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Editor route: inline mono "<name> · v<version>" after title */}
|
||||||
|
{isLast && crumb.subtitle && (
|
||||||
|
<span className="text-mono text-mute shrink-0 ml-2">{crumb.subtitle}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -134,14 +141,22 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Crumb = { label: string; to?: string; subtitle?: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Breadcrumb из current route — простой mapping для типичных страниц.
|
* Breadcrumb из current route. Для editor route ('/dictionaries/$name')
|
||||||
|
* добавляет displayName + version (mono subtitle) per redesign prototype.
|
||||||
*/
|
*/
|
||||||
function useBreadcrumb(): { label: string; to?: string }[] {
|
function useBreadcrumb(): Crumb[] {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
|
|
||||||
|
// Editor route: fetch dict detail для display name + schema version.
|
||||||
|
const editorMatch = path.match(/^\/dictionaries\/([^/]+)/)
|
||||||
|
const editorName = editorMatch ? decodeURIComponent(editorMatch[1]) : undefined
|
||||||
|
const editorDict = useDictionaryDetail(editorName)
|
||||||
|
|
||||||
if (path === '/' || path === '') {
|
if (path === '/' || path === '') {
|
||||||
return [{ label: t('nav.home') }]
|
return [{ label: t('nav.home') }]
|
||||||
}
|
}
|
||||||
@@ -161,12 +176,20 @@ function useBreadcrumb(): { label: string; to?: string }[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootLabel = ROOT_LABELS[first] ?? first
|
const rootLabel = ROOT_LABELS[first] ?? first
|
||||||
const crumbs: { label: string; to?: string }[] = [
|
const crumbs: Crumb[] = [
|
||||||
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
|
{ label: rootLabel, to: segments.length === 1 ? undefined : `/${first}` },
|
||||||
]
|
]
|
||||||
|
|
||||||
if (segments.length >= 2) {
|
if (segments.length >= 2) {
|
||||||
crumbs.push({ label: decodeURIComponent(segments[1]) })
|
// Editor route enrichment: displayName + version
|
||||||
|
if (editorName && editorDict.data) {
|
||||||
|
crumbs.push({
|
||||||
|
label: editorDict.data.displayName ?? editorDict.data.name,
|
||||||
|
subtitle: `${editorDict.data.name} · v${editorDict.data.schemaVersion}`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
crumbs.push({ label: decodeURIComponent(segments[1]) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return crumbs
|
return crumbs
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
|
||||||
Panel,
|
Panel,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
Table,
|
Table,
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
useAudit,
|
useAudit,
|
||||||
useDictionaryDetail,
|
useDictionaryDetail,
|
||||||
@@ -553,81 +552,9 @@ function DictionaryDetail() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PageHeader
|
{/* PageHeader removed per redesign: title/version вынесены в TopBar
|
||||||
breadcrumb={
|
* breadcrumb (см. components/layout/TopBar.tsx). Actions перенесены
|
||||||
<div className="flex items-center gap-3 text-body">
|
* в InfoPanel (Time-travel, AOI) + tab toolbar (Schema, +Запись). */}
|
||||||
<Link to="/dictionaries" className="text-ink-2 hover:text-accent">
|
|
||||||
← {t('nav.dictionaries')}
|
|
||||||
</Link>
|
|
||||||
{scope && (
|
|
||||||
<span className="flex items-center gap-1.5 text-ink-2">
|
|
||||||
<span className="text-mute/70">·</span>
|
|
||||||
<span
|
|
||||||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="font-medium">{t(`dict.list.section.${scope}`)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
title={detailQuery.data?.displayName ?? name}
|
|
||||||
description={
|
|
||||||
detailQuery.data?.description
|
|
||||||
? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}`
|
|
||||||
: `${totalRecords} ${t('dict.list.records')}`
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
// flex-wrap: при узком viewport кнопки переносятся на новую row
|
|
||||||
// вместо вмятия в 2 строки внутри одной кнопки. whitespace-nowrap на
|
|
||||||
// каждой Button — label всегда single line. Combination: buttons
|
|
||||||
// expand to fit text, и весь toolbar wraps below header при узком
|
|
||||||
// экране, а не давит лейблы.
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
|
|
||||||
disabled={!detailQuery.data}
|
|
||||||
onClick={() => setAoiOpen(true)}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('aoi.button')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
|
|
||||||
disabled={!detailQuery.data}
|
|
||||||
onClick={() => setTimeTravelOpen((v) => !v)}
|
|
||||||
aria-pressed={Boolean(timeTravelAt)}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('timeTravel.button')}
|
|
||||||
</Button>
|
|
||||||
{canMutate && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
|
||||||
disabled={!detailQuery.data}
|
|
||||||
onClick={() => setSchemaEditOpen(true)}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('schema.action.editSchema')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
|
||||||
disabled={!detailQuery.data}
|
|
||||||
onClick={() => setEdit({ kind: 'create' })}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('dict.action.create')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 3-col layout per handoff prototype design/compact.html:
|
{/* 3-col layout per handoff prototype design/compact.html:
|
||||||
[Sidebar (220px global)] [InfoPanel 220px] [main content]
|
[Sidebar (220px global)] [InfoPanel 220px] [main content]
|
||||||
@@ -635,7 +562,16 @@ function DictionaryDetail() {
|
|||||||
На <lg viewport schedule переключается в stacked (info above content). */}
|
На <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">
|
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<EditorInfoPanel detail={detailQuery.data} recordCount={totalRecords} />
|
<EditorInfoPanel
|
||||||
|
detail={detailQuery.data}
|
||||||
|
recordCount={totalRecords}
|
||||||
|
actions={{
|
||||||
|
onTimeTravel: () => setTimeTravelOpen((v) => !v),
|
||||||
|
timeTravelActive: Boolean(timeTravelAt),
|
||||||
|
onAoi: () => setAoiOpen(true),
|
||||||
|
aoiActive: Boolean(aoi),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
@@ -644,7 +580,9 @@ function DictionaryDetail() {
|
|||||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editor tabs per handoff Stage 3.4 */}
|
{/* Editor tabs per handoff Stage 3.4 + redesign:
|
||||||
|
* - counts в labels (Записи 127, Связи 6, Поля 12)
|
||||||
|
* - right-aligned actions: Schema edit + +Запись per редизайн */}
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<EditorTabBar
|
<EditorTabBar
|
||||||
active={urlSearch.tab ?? 'records'}
|
active={urlSearch.tab ?? 'records'}
|
||||||
@@ -657,6 +595,36 @@ function DictionaryDetail() {
|
|||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
counts={{
|
||||||
|
records: totalRecords,
|
||||||
|
relations: (detailQuery.data ? extractOutgoingFkCount(detailQuery.data.schemaJson) : 0),
|
||||||
|
fields: Object.keys(detailQuery.data?.schemaJson?.properties ?? {}).length,
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
canMutate ? (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
label={t('schema.action.editSchema')}
|
||||||
|
variant="default"
|
||||||
|
icon={<GearIcon weight="regular" />}
|
||||||
|
disabled={!detailQuery.data}
|
||||||
|
onClick={() => setSchemaEditOpen(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<PlusIcon weight="bold" size={14} />}
|
||||||
|
disabled={!detailQuery.data}
|
||||||
|
onClick={() => setEdit({ kind: 'create' })}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{t('dict.action.create')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1401,6 +1369,20 @@ function BulkCloseDialog({
|
|||||||
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
|
* уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
|
||||||
*/
|
*/
|
||||||
/** snake_case → 'Snake Case' для column header'ов. */
|
/** snake_case → 'Snake Case' для column header'ов. */
|
||||||
|
/** Count unique outgoing FK targets across schema properties (x-references). */
|
||||||
|
const extractOutgoingFkCount = (schema: import('@/api/client').JsonSchema | undefined): number => {
|
||||||
|
if (!schema?.properties) return 0
|
||||||
|
const targets = new Set<string>()
|
||||||
|
for (const prop of Object.values(schema.properties)) {
|
||||||
|
const ref = prop['x-references']
|
||||||
|
if (typeof ref === 'string' && ref.length > 0) {
|
||||||
|
const [target] = ref.split('.')
|
||||||
|
if (target) targets.add(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets.size
|
||||||
|
}
|
||||||
|
|
||||||
const humanize = (key: string): string =>
|
const humanize = (key: string): string =>
|
||||||
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
||||||
|
|
||||||
@@ -1445,9 +1427,15 @@ type EditorTab = 'records' | 'relations' | 'fields' | 'json' | 'events' | 'histo
|
|||||||
function EditorTabBar({
|
function EditorTabBar({
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
|
counts,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
active: EditorTab
|
active: EditorTab
|
||||||
onChange: (tab: EditorTab) => void
|
onChange: (tab: EditorTab) => void
|
||||||
|
/** Optional per-tab counters — render как inline mono badge per redesign. */
|
||||||
|
counts?: Partial<Record<EditorTab, number | undefined>>
|
||||||
|
/** Right-aligned action buttons (Schema / +Запись) per redesign prototype. */
|
||||||
|
actions?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const tabs: { id: EditorTab; label: string }[] = [
|
const tabs: { id: EditorTab; label: string }[] = [
|
||||||
@@ -1462,6 +1450,7 @@ function EditorTabBar({
|
|||||||
<div className="flex items-center gap-0 border-b border-line w-full">
|
<div className="flex items-center gap-0 border-b border-line w-full">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = active === tab.id
|
const isActive = active === tab.id
|
||||||
|
const count = counts?.[tab.id]
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -1469,16 +1458,20 @@ function EditorTabBar({
|
|||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
className={[
|
className={[
|
||||||
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2',
|
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5',
|
||||||
isActive
|
isActive
|
||||||
? 'border-accent text-ink font-semibold'
|
? 'border-accent text-ink font-semibold'
|
||||||
: 'border-transparent text-ink-2 hover:text-ink',
|
: 'border-transparent text-ink-2 hover:text-ink',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{typeof count === 'number' && count > 0 && (
|
||||||
|
<span className="text-mono text-mute">{count}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{actions && <div className="ml-auto flex items-center gap-2 pl-2">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user