feat(editor): left info panel + 3-col layout per handoff prototype
Handoff prototype design/compact.html показывает editor как 3-col layout:
[Sidebar 220 (global nav)] [InfoPanel 220 (dict metadata)] [main content].
Текущая implementation была 2-col (nav + everything).
NEW src/components/editor/EditorInfoPanel.tsx:
- Scope badge (PUBLIC/INTERNAL/RESTRICTED) — top
- ОПИСАНИЕ section — caption + dict.description body
- Metadata 2x2 grid: Bundle / Версия / Записей / Локали
(mono text-ink-2, cap labels)
- СВЯЗИ section с двумя группами:
→ ссылается outgoing FK list (name links к /dictionaries/$name)
← используют incoming refBy list (deduplicated sourceDict names)
- sticky lg:top-2 self-start чтобы panel оставалась видна при scroll
- lg:max-h-[calc(100vh-7rem)] overflow-y-auto если metadata высокий
src/routes/dictionaries.$name.tsx:
- Обернул содержимое (banner + tabs + tab content) в:
<div className='grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6'>
<EditorInfoPanel ... />
<div>{banner + tabs + content}</div>
</div>
- На <lg переключается в stacked (info above content) — single column
благодаря grid-cols-1.
PageHeader остаётся выше grid (full-width breadcrumb + title + actions).
Action buttons (AOI / Time-travel / Schema edit / Создать) живут в
PageHeader.actions slot — handoff prototype показывает их в info panel,
но рядом с title лучше discoverable. Может уйти в info panel в future
refactor.
Tests: 116 pass. TS strict clean.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/ui'
|
||||
import { useDictionaryDependents } from '@/api/queries'
|
||||
import type { DictionaryDetail } from '@/api/client'
|
||||
|
||||
/**
|
||||
* EditorInfoPanel — left rail с dict metadata per handoff prototype Screen 2.
|
||||
*
|
||||
* <p>Содержимое (top → bottom):
|
||||
* <ul>
|
||||
* <li>Scope badge (PUBLIC/INTERNAL/RESTRICTED)</li>
|
||||
* <li>ОПИСАНИЕ caption + description text</li>
|
||||
* <li>BUNDLE / ВЕРСИЯ / ЗАПИСЕЙ / ЛОКАЛИ metadata grid</li>
|
||||
* <li>СВЯЗИ — → ссылается (outgoing FK names) + ← используют (incoming dict names)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Action buttons (Экспорт / Time-travel) идут в PageHeader actions slot,
|
||||
* не в info panel — handoff prototype показывает им в info panel но семантика
|
||||
* редизайна: actions около title для discoverability.
|
||||
*/
|
||||
export type EditorInfoPanelProps = {
|
||||
detail: DictionaryDetail
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
export function EditorInfoPanel({ detail, recordCount }: EditorInfoPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: refByRaw } = useDictionaryDependents(detail.name)
|
||||
|
||||
const outgoingNames = useMemo(() => extractOutgoingFk(detail.schemaJson), [detail.schemaJson])
|
||||
const incomingNames = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const dep of refByRaw ?? []) seen.add(dep.sourceDict)
|
||||
return Array.from(seen).sort()
|
||||
}, [refByRaw])
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-2 self-start space-y-4 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto pr-2">
|
||||
{/* Scope badge — top of panel */}
|
||||
<Badge variant="info">{detail.scope}</Badge>
|
||||
|
||||
{/* Description */}
|
||||
{detail.description && (
|
||||
<Section label={t('editor.info.description', { defaultValue: 'Описание' })}>
|
||||
<p className="text-body text-ink-2 leading-relaxed">{detail.description}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Metadata grid */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<MetaCell label={t('editor.info.bundle', { defaultValue: 'Bundle' })}>
|
||||
<span className="text-mono text-ink-2">{detail.bundle}</span>
|
||||
</MetaCell>
|
||||
<MetaCell label={t('editor.info.version', { defaultValue: 'Версия' })}>
|
||||
<span className="text-mono text-ink-2">v{detail.schemaVersion}</span>
|
||||
</MetaCell>
|
||||
<MetaCell label={t('editor.info.records', { defaultValue: 'Записей' })}>
|
||||
<span className="text-mono text-ink-2 tabular-nums">{recordCount}</span>
|
||||
</MetaCell>
|
||||
<MetaCell label={t('editor.info.locales', { defaultValue: 'Локали' })}>
|
||||
<span className="text-mono text-ink-2">{detail.supportedLocales.join(', ')}</span>
|
||||
</MetaCell>
|
||||
</div>
|
||||
|
||||
{/* Relations */}
|
||||
{(outgoingNames.length > 0 || incomingNames.length > 0) && (
|
||||
<Section
|
||||
label={t('editor.info.relations', {
|
||||
defaultValue: 'Связи',
|
||||
count: outgoingNames.length + incomingNames.length,
|
||||
})}
|
||||
>
|
||||
{outgoingNames.length > 0 && (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="text-cap text-mute">
|
||||
→ {t('editor.info.outgoing', { defaultValue: 'ссылается' })}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{outgoingNames.map((name) => (
|
||||
<li key={name}>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name }}
|
||||
className="text-mono text-accent hover:underline"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{incomingNames.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-cap text-mute">
|
||||
← {t('editor.info.incoming', { defaultValue: 'используют' })}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{incomingNames.map((name) => (
|
||||
<li key={name}>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name }}
|
||||
className="text-mono text-accent hover:underline"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-cap text-mute">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaCell({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-cap text-mute">{label}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const extractOutgoingFk = (
|
||||
schema: import('@/api/client').JsonSchema | undefined,
|
||||
): string[] => {
|
||||
if (!schema?.properties) return []
|
||||
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 Array.from(targets).sort()
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
|
||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||
import { RecordDrawer } from '@/components/record/RecordDrawer'
|
||||
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
|
||||
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
@@ -621,14 +622,22 @@ function DictionaryDetail() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 3-col layout per handoff prototype design/compact.html:
|
||||
[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">
|
||||
{detailQuery.data && (
|
||||
<EditorInfoPanel detail={detailQuery.data} recordCount={totalRecords} />
|
||||
)}
|
||||
|
||||
<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} />
|
||||
)}
|
||||
|
||||
{/* Editor tabs per handoff Stage 3.4:
|
||||
Records | Relations | Fields | JSON | Events | History.
|
||||
URL-synced через ?tab=. Default — records (records table + filters). */}
|
||||
{/* Editor tabs per handoff Stage 3.4 */}
|
||||
{detailQuery.data && (
|
||||
<EditorTabBar
|
||||
active={urlSearch.tab ?? 'records'}
|
||||
@@ -945,6 +954,9 @@ function DictionaryDetail() {
|
||||
</>
|
||||
)}
|
||||
{/* === end records tab content === */}
|
||||
</div>
|
||||
</div>
|
||||
{/* === end 3-col grid === */}
|
||||
|
||||
<RecordDrawer
|
||||
open={edit.kind === 'create' || edit.kind === 'edit'}
|
||||
|
||||
Reference in New Issue
Block a user