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 { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
import { RecordDrawer } from '@/components/record/RecordDrawer'
|
import { RecordDrawer } from '@/components/record/RecordDrawer'
|
||||||
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
|
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
|
||||||
|
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
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. */}
|
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<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 */}
|
||||||
Records | Relations | Fields | JSON | Events | History.
|
|
||||||
URL-synced через ?tab=. Default — records (records table + filters). */}
|
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<EditorTabBar
|
<EditorTabBar
|
||||||
active={urlSearch.tab ?? 'records'}
|
active={urlSearch.tab ?? 'records'}
|
||||||
@@ -945,6 +954,9 @@ function DictionaryDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* === end records tab content === */}
|
{/* === end records tab content === */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* === end 3-col grid === */}
|
||||||
|
|
||||||
<RecordDrawer
|
<RecordDrawer
|
||||||
open={edit.kind === 'create' || edit.kind === 'edit'}
|
open={edit.kind === 'create' || edit.kind === 'edit'}
|
||||||
|
|||||||
Reference in New Issue
Block a user