@elytracms/studio 0.0.45 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/cms.ts +1 -0
- package/convex/schema.ts +3 -0
- package/dist/components/ai/ai-chat-panel-lazy.d.ts +1 -0
- package/dist/components/ai/ai-chat-panel-lazy.js +21 -0
- package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -0
- package/dist/components/ai/ai-chat-panel.d.ts +10 -0
- package/dist/components/ai/ai-chat-panel.js +211 -0
- package/dist/components/ai/ai-chat-panel.js.map +1 -0
- package/dist/components/ai/ai-cms-panel.d.ts +9 -0
- package/dist/components/ai/ai-cms-panel.js +24 -0
- package/dist/components/ai/ai-cms-panel.js.map +1 -0
- package/dist/components/ai/ai-panel-toggle.d.ts +8 -0
- package/dist/components/ai/ai-panel-toggle.js +27 -0
- package/dist/components/ai/ai-panel-toggle.js.map +1 -0
- package/dist/components/ai/chat-ui.d.ts +36 -0
- package/dist/components/ai/chat-ui.js +72 -0
- package/dist/components/ai/chat-ui.js.map +1 -0
- package/dist/components/ai/proposal-card.d.ts +15 -0
- package/dist/components/ai/proposal-card.js +49 -0
- package/dist/components/ai/proposal-card.js.map +1 -0
- package/dist/components/ai/translate-document-action.d.ts +14 -0
- package/dist/components/ai/translate-document-action.js +38 -0
- package/dist/components/ai/translate-document-action.js.map +1 -0
- package/dist/components/ai/translate-document-dialog.d.ts +5 -0
- package/dist/components/ai/translate-document-dialog.js +97 -0
- package/dist/components/ai/translate-document-dialog.js.map +1 -0
- package/dist/components/app-views/default-modules.js +2 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/cms/detail-editor.js +5 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +41 -0
- package/dist/components/cms/document-diff-view.js +299 -0
- package/dist/components/cms/document-diff-view.js.map +1 -0
- package/dist/components/cms/version-compare.d.ts +7 -13
- package/dist/components/cms/version-compare.js +24 -365
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +1 -1
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/studio-shell.js +2 -1
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +81 -0
- package/dist/lib/ai/chat-state.js +103 -0
- package/dist/lib/ai/chat-state.js.map +1 -0
- package/dist/lib/ai/create-flow.d.ts +132 -0
- package/dist/lib/ai/create-flow.js +280 -0
- package/dist/lib/ai/create-flow.js.map +1 -0
- package/dist/lib/ai/index.d.ts +6 -0
- package/dist/lib/ai/index.js +6 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/rich-text-translate.d.ts +45 -0
- package/dist/lib/ai/rich-text-translate.js +229 -0
- package/dist/lib/ai/rich-text-translate.js.map +1 -0
- package/dist/lib/ai/route-provider.d.ts +50 -0
- package/dist/lib/ai/route-provider.js +60 -0
- package/dist/lib/ai/route-provider.js.map +1 -0
- package/dist/lib/ai/route.d.ts +12 -0
- package/dist/lib/ai/route.js +42 -3
- package/dist/lib/ai/route.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +77 -0
- package/dist/lib/ai/tools.js +74 -0
- package/dist/lib/ai/tools.js.map +1 -0
- package/dist/lib/ai/translate-document.d.ts +53 -0
- package/dist/lib/ai/translate-document.js +316 -0
- package/dist/lib/ai/translate-document.js.map +1 -0
- package/dist/lib/ai-panel.d.ts +20 -0
- package/dist/lib/ai-panel.js +48 -0
- package/dist/lib/ai-panel.js.map +1 -0
- package/dist/lib/cms-views/view-model.d.ts +3 -3
- package/dist/lib/i18n/messages.en.js +57 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +2 -0
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/package.json +2 -2
|
@@ -1,42 +1,29 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { ArrowRight, ChevronDown, ChevronLeft, ChevronRight,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useMemo } from 'react';
|
|
3
|
+
import { ArrowRight, ChevronDown, ChevronLeft, ChevronRight, RotateCcw } from 'lucide-react';
|
|
4
4
|
import { diffDocuments } from '@elytracms/core/content';
|
|
5
|
-
import {
|
|
6
|
-
import { Badge, Button, Code, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Row, Spacer, Stack, Text, } from '@elytracms/studio/ui';
|
|
5
|
+
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Row, Spacer, Stack, Text, } from '@elytracms/studio/ui';
|
|
7
6
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
8
|
-
import { localeChipLabel } from '@elytracms/studio/lib/locale';
|
|
9
|
-
import { cn } from '@elytracms/studio/lib/utils';
|
|
10
|
-
import { readDiffViewMode, storeDiffViewMode } from '@elytracms/studio/lib/diff-view-preference';
|
|
11
7
|
import { useCollectionView } from '../collection-view/collection-view-context';
|
|
12
|
-
import {
|
|
13
|
-
import { toRefArray } from './cell';
|
|
14
|
-
import { DIFF_TRUNCATE_THRESHOLD, mergeWordDiff, richTextToText, truncateText, truncateTokens, wordDiff, } from './version-compare-text';
|
|
8
|
+
import { DocumentDiffView } from './document-diff-view';
|
|
15
9
|
import { useDocumentEditor } from './document-editor-context';
|
|
16
10
|
import { useDocumentVersionsQuery } from './use-document-versions';
|
|
17
11
|
import { useRestoreVersion } from './use-restore-version';
|
|
18
12
|
import { formatVersionTimestamp } from './versions-model';
|
|
19
13
|
/**
|
|
20
|
-
* EC-298 — the version compare view
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* (changed words lit in place — rich text via its markdown projection); assets as
|
|
28
|
-
* thumbnails; relations as titles. A value past a length threshold collapses
|
|
29
|
-
* behind a per-row "Show more" (+ a global "Expand/Collapse all") — presentation
|
|
30
|
-
* only, the semantic/identity diff underneath (EC-298) is never touched. Rows
|
|
31
|
-
* zebra-stripe for scanability. Each field/locale (and each block, at property
|
|
32
|
-
* level) restores back to the base version with a single ↩.
|
|
14
|
+
* EC-298/317 — the version compare view: the sticky **Compare** content tab. This
|
|
15
|
+
* shell owns the version SELECTION chrome (the two re-pickable sides, the base
|
|
16
|
+
* stepper, restore-whole); the change-list itself is drawn by the shared
|
|
17
|
+
* {@link DocumentDiffView} (EC-317 descriptor lane, unified/side-by-side toggle,
|
|
18
|
+
* truncation, zebra) — the same renderer the EC-199 AI proposal review reuses, so
|
|
19
|
+
* there is exactly one diff renderer. Each field/locale (and block) restores back to
|
|
20
|
+
* the base version with a single ↩ (wired through `DocumentDiffView`'s `restore`).
|
|
33
21
|
*/
|
|
34
22
|
export function VersionCompare() {
|
|
35
23
|
const source = useCollectionView();
|
|
36
24
|
const { collection, docRef, draft, dirty, viewReadOnly, save, replaceDraft, compare, setCompareBase, setCompareTarget, } = useDocumentEditor();
|
|
37
25
|
const { versionsQuery, versionKey } = useDocumentVersionsQuery(docRef);
|
|
38
|
-
// EC-325 — the shared restore sequence (flush → restore → replaceDraft →
|
|
39
|
-
// invalidate). `busy` gates the restore controls below while it round-trips.
|
|
26
|
+
// EC-325 — the shared restore sequence (flush → restore → replaceDraft → invalidate).
|
|
40
27
|
const { restore: restoreVersion, busy } = useRestoreVersion({
|
|
41
28
|
source,
|
|
42
29
|
docRef,
|
|
@@ -45,41 +32,6 @@ export function VersionCompare() {
|
|
|
45
32
|
save,
|
|
46
33
|
replaceDraft,
|
|
47
34
|
});
|
|
48
|
-
// EC-317 — the unified/side-by-side toggle is a transient, personal-local view
|
|
49
|
-
// preference (same class as the theme/rail prefs): read once at mount, written
|
|
50
|
-
// back on every change, never round-tripped through the project graph.
|
|
51
|
-
const [mode, setModeState] = useState(() => readDiffViewMode());
|
|
52
|
-
const setMode = useCallback((next) => {
|
|
53
|
-
setModeState(next);
|
|
54
|
-
storeDiffViewMode(next);
|
|
55
|
-
}, []);
|
|
56
|
-
// EC-317 — long-value truncation state: an `expandAll` flag plus per-row
|
|
57
|
-
// overrides. Presentation-only (never recomputes the diff), reset is implicit
|
|
58
|
-
// (in-session state, like the rest of the compare selection).
|
|
59
|
-
const [expandAll, setExpandAll] = useState(false);
|
|
60
|
-
const [expandedRows, setExpandedRows] = useState(() => new Set());
|
|
61
|
-
const toggleExpand = useCallback((id) => {
|
|
62
|
-
setExpandedRows((prev) => {
|
|
63
|
-
const next = new Set(prev);
|
|
64
|
-
if (next.has(id))
|
|
65
|
-
next.delete(id);
|
|
66
|
-
else
|
|
67
|
-
next.add(id);
|
|
68
|
-
return next;
|
|
69
|
-
});
|
|
70
|
-
}, []);
|
|
71
|
-
const isExpanded = useCallback((id) => expandAll || expandedRows.has(id), [expandAll, expandedRows]);
|
|
72
|
-
const toggleExpandAll = useCallback(() => {
|
|
73
|
-
setExpandAll((prev) => {
|
|
74
|
-
const next = !prev;
|
|
75
|
-
// Collapsing all clears per-row overrides too, so "Collapse all" always
|
|
76
|
-
// means "everything is collapsed" rather than "back to before I expanded".
|
|
77
|
-
if (!next)
|
|
78
|
-
setExpandedRows(new Set());
|
|
79
|
-
return next;
|
|
80
|
-
});
|
|
81
|
-
}, []);
|
|
82
|
-
const diffDisplay = useMemo(() => ({ mode, expandAll, isExpanded, toggleExpand }), [mode, expandAll, isExpanded, toggleExpand]);
|
|
83
35
|
const view = versionsQuery.data ?? null;
|
|
84
36
|
const versions = useMemo(() => view?.versions ?? [], [view]);
|
|
85
37
|
const baseRecord = compare ? versions.find((v) => v.version === compare.base) : undefined;
|
|
@@ -87,21 +39,6 @@ export function VersionCompare() {
|
|
|
87
39
|
const diff = useMemo(() => baseRecord && targetSnapshot
|
|
88
40
|
? diffDocuments(collection, baseRecord.snapshot, targetSnapshot)
|
|
89
41
|
: { changes: [], unchangedCount: 0 }, [collection, baseRecord, targetSnapshot]);
|
|
90
|
-
const fieldOrder = useMemo(() => new Map(collection.fields.map((f, i) => [f.name, i])), [collection]);
|
|
91
|
-
const fieldByName = useMemo(() => new Map(collection.fields.map((f) => [f.name, f])), [collection]);
|
|
92
|
-
const localeRank = useMemo(() => new Map(source.localeConfig.locales.map((l, i) => [l, i])), [source.localeConfig]);
|
|
93
|
-
const groups = useMemo(() => groupByField(diff.changes, fieldOrder, localeRank), [diff.changes, fieldOrder, localeRank]);
|
|
94
|
-
// EC-317 zebra striping: a running index across every rendered row (not reset
|
|
95
|
-
// per field group), so consecutive groups keep alternating rather than each
|
|
96
|
-
// restarting on the same shade.
|
|
97
|
-
const groupRows = useMemo(() => {
|
|
98
|
-
let rowIndex = 0;
|
|
99
|
-
return groups.map((group) => {
|
|
100
|
-
const zebraStart = rowIndex;
|
|
101
|
-
rowIndex += group.entries.length;
|
|
102
|
-
return { group, field: fieldByName.get(group.field), zebraStart };
|
|
103
|
-
});
|
|
104
|
-
}, [groups, fieldByName]);
|
|
105
42
|
const restore = useCallback(async (scope) => {
|
|
106
43
|
if (!compare)
|
|
107
44
|
return;
|
|
@@ -138,300 +75,22 @@ export function VersionCompare() {
|
|
|
138
75
|
.filter((v) => v.version !== compare.base)
|
|
139
76
|
.map((v) => ({ value: v.version, label: `v${v.version}`, meta: formatVersionTimestamp(v.createdAt) })),
|
|
140
77
|
];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
throw new Error('useDiffDisplay must be rendered under the VersionCompare diff area');
|
|
153
|
-
return ctx;
|
|
154
|
-
}
|
|
155
|
-
/** Shared grid template so the descriptor lane + header captions line up. 0.0.41:
|
|
156
|
-
* a third slim column holds the field-row restore action — see {@link FieldRow}. */
|
|
157
|
-
const DIFF_ROW_GRID = 'grid grid-cols-[7.5rem_1fr_1.75rem] items-start gap-x-4';
|
|
158
|
-
/** A before | after grid with a clear central divider (0.0.39: a full-height ruled
|
|
159
|
-
* gutter — the same proportions in every row type, so the before/after split reads
|
|
160
|
-
* consistently across field rows, block-prop rows and word diffs). The columns keep
|
|
161
|
-
* a symmetric inset off the divider so values never touch it. */
|
|
162
|
-
function TwoCol({ before, after, className }) {
|
|
163
|
-
return (_jsxs("div", { className: cn('relative grid grid-cols-2 gap-x-8', className), children: [_jsx("div", { className: "pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border", "aria-hidden": true }), _jsx("div", { className: "min-w-0 pr-1", children: before }), _jsx("div", { className: "min-w-0 pl-1", children: after })] }));
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Mode-aware value display (EC-317): side-by-side keeps the before | after
|
|
167
|
-
* columns; unified stacks a single "− before" / "+ after" pair in one lane,
|
|
168
|
-
* omitting whichever side doesn't exist (`added`/`removed` kind).
|
|
169
|
-
*/
|
|
170
|
-
function DiffColumns({ before, after, kind }) {
|
|
171
|
-
const { mode } = useDiffDisplay();
|
|
172
|
-
if (mode === 'unified') {
|
|
173
|
-
return (_jsxs(Stack, { gap: "1", children: [kind !== 'added' ? _jsx(UnifiedLine, { tone: "removed", children: before }) : null, kind !== 'removed' ? _jsx(UnifiedLine, { tone: "added", children: after }) : null] }));
|
|
174
|
-
}
|
|
175
|
-
return _jsx(TwoCol, { before: before, after: after });
|
|
176
|
-
}
|
|
177
|
-
function UnifiedLine({ tone, children }) {
|
|
178
|
-
return (_jsxs(Row, { gap: "2", align: "start", className: cn('rounded-sm px-1.5 py-1', tone === 'removed' ? 'bg-destructive/10' : 'bg-success/10'), children: [_jsx(Text, { size: "xs", mono: true, "aria-hidden": true, className: cn('mt-0.5 shrink-0 select-none', tone === 'removed' ? 'text-destructive' : 'text-success'), children: tone === 'removed' ? '−' : '+' }), _jsx("div", { className: "min-w-0 flex-1", children: children })] }));
|
|
179
|
-
}
|
|
180
|
-
/** Wraps truncatable content with its per-row "Show more"/"Show less" control —
|
|
181
|
-
* hidden while the global "expand all" is active (nothing left to toggle). */
|
|
182
|
-
function ExpandToggle({ id, isLong, expanded, children, }) {
|
|
183
|
-
const { expandAll, toggleExpand } = useDiffDisplay();
|
|
184
|
-
return (_jsxs("div", { children: [children, isLong && !expandAll ? (_jsx(Button, { variant: "link", size: "sm", className: "h-auto p-0 text-xs", onClick: () => toggleExpand(id), children: expanded ? translate('cms.version.showLess') : translate('cms.version.showMore') })) : null] }));
|
|
78
|
+
const columnHeaders = {
|
|
79
|
+
unified: translate('cms.version.unifiedCaption', { base: compare.base, target: targetLabel }),
|
|
80
|
+
before: translate('cms.version.beforeVersion', { version: compare.base }),
|
|
81
|
+
after: translate('cms.version.afterTarget', { target: targetLabel }),
|
|
82
|
+
};
|
|
83
|
+
return (_jsxs(Stack, { gap: "3", className: "mx-auto w-full max-w-5xl p-4 sm:p-6", children: [_jsxs(Row, { gap: "2", align: "center", wrap: true, className: "rounded-md border border-border/70 bg-muted/30 px-3 py-2", children: [_jsx(VersionMenu, { caption: translate('cms.version.baseCaption'), current: `v${compare.base}`, meta: formatVersionTimestamp(baseRecord.createdAt), options: baseOptions, onSelect: (v) => setCompareBase(v) }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx(VersionMenu, { caption: translate('cms.version.compareWithCaption'), current: targetLabel, options: targetOptions, onSelect: (v) => setCompareTarget(v) }), _jsx(Spacer, {}), _jsxs(Row, { gap: "1", children: [_jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => newerBase !== undefined && setCompareBase(newerBase), disabled: newerBase === undefined, title: translate('cms.version.compareNewer'), "aria-label": translate('cms.version.compareNewer'), children: _jsx(ChevronLeft, {}) }), _jsx(Button, { variant: "outline", size: "icon-sm", onClick: () => olderBase !== undefined && setCompareBase(olderBase), disabled: olderBase === undefined, title: translate('cms.version.compareOlder'), "aria-label": translate('cms.version.compareOlder'), children: _jsx(ChevronRight, {}) })] }), canRestore ? (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void restore({ kind: 'all' }), disabled: busy || diff.changes.length === 0, title: translate('cms.version.restoreWholeTitle', { version: compare.base }), children: [_jsx(RotateCcw, {}), " ", translate('cms.version.restoreVersion')] })) : null] }), diff.changes.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", tone: "muted", className: "px-0.5", children: translate('cms.version.noDifferences') }), _jsx(CompareNotice, { children: translate('cms.version.versionsIdentical') })] })) : (_jsx(DocumentDiffView, { diff: diff, collection: collection, columnHeaders: columnHeaders, restore: {
|
|
84
|
+
baseVersion: compare.base,
|
|
85
|
+
canRestore,
|
|
86
|
+
busy,
|
|
87
|
+
onRestore: (scope) => void restore(scope),
|
|
88
|
+
}, summaryHint: !canRestore ? translate('cms.version.comparingPastVersionsHint') : undefined }))] }));
|
|
185
89
|
}
|
|
186
90
|
function VersionMenu({ caption, current, meta, options, onSelect, }) {
|
|
187
91
|
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", size: "sm", className: "gap-1.5", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: caption }), _jsx(Text, { size: "sm", weight: "medium", mono: true, children: current }), meta ? (_jsx(Text, { size: "xs", tone: "muted", className: "hidden sm:inline", children: meta })) : null, _jsx(ChevronDown, { className: "size-3.5 text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-72 overflow-auto", children: options.map((o) => (_jsxs(DropdownMenuItem, { onClick: () => onSelect(o.value), className: "gap-2", children: [_jsx(Text, { size: "sm", weight: "medium", mono: o.value !== 'draft', children: o.label }), o.meta ? (_jsx(Text, { size: "xs", tone: "muted", children: o.meta })) : null] }, String(o.value)))) })] }));
|
|
188
92
|
}
|
|
189
|
-
/** The unified/side-by-side segmented toggle (EC-317) — same pill language as
|
|
190
|
-
* the header's `ViewTabs`/locale switcher, so it reads as one lens control. */
|
|
191
|
-
function DiffViewToggle({ mode, onChange }) {
|
|
192
|
-
return (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/50 p-0.5", role: "group", "aria-label": translate('cms.version.viewModeAriaLabel'), "data-testid": "diff-view-toggle", children: [_jsx(DiffViewOption, { active: mode === 'sideBySide', label: translate('cms.version.viewSideBySide'), onClick: () => onChange('sideBySide') }), _jsx(DiffViewOption, { active: mode === 'unified', label: translate('cms.version.viewUnified'), onClick: () => onChange('unified') })] }));
|
|
193
|
-
}
|
|
194
|
-
function DiffViewOption({ active, label, onClick }) {
|
|
195
|
-
return (_jsx("button", { type: "button", "aria-pressed": active, "data-testid": `diff-view-option-${active ? 'active' : 'inactive'}`, onClick: onClick, className: cn('rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors', active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'), children: label }));
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* One field's diff rows (EC-317: each locale variant is its own zebra-striped
|
|
199
|
-
* row with a descriptor lane — label reused from the studio's EC-241
|
|
200
|
-
* `fieldLabel` active-locale resolver where a schema field exists, falling
|
|
201
|
-
* back to the diff engine's default-locale label for a schema-drift key).
|
|
202
|
-
*/
|
|
203
|
-
function FieldGroup({ group, field, zebraStart, baseVersion, canRestore, busy, onRestore, }) {
|
|
204
|
-
const label = field ? fieldLabel(field) : group.label;
|
|
205
|
-
return (_jsx("div", { className: "border-t border-border/30", children: group.entries.map((entry, i) => {
|
|
206
|
-
const rowId = `${group.field}:${entry.locale ?? '_'}`;
|
|
207
|
-
// 0.0.39: ONE restore per row, right-aligned in the descriptor lane. A blocks
|
|
208
|
-
// field restores per BLOCK (each block owns its own restore in its header), so
|
|
209
|
-
// the field-level restore is dropped there — no more two icons per row.
|
|
210
|
-
const fieldRestore = canRestore && !entry.blocks ? (_jsx(RestoreIcon, { busy: busy, title: entry.locale
|
|
211
|
-
? translate('cms.version.restoreFieldLocale', {
|
|
212
|
-
field: label,
|
|
213
|
-
locale: localeChipLabel(entry.locale),
|
|
214
|
-
version: baseVersion,
|
|
215
|
-
})
|
|
216
|
-
: translate('cms.version.restoreField', { field: label, version: baseVersion }), onClick: () => onRestore(fieldScope(entry)) })) : null;
|
|
217
|
-
return (_jsx(FieldRow, { zebra: (zebraStart + i) % 2 === 1, descriptor: _jsx(FieldDescriptor, { label: label, locale: entry.locale, kind: entry.kind }), restore: fieldRestore, children: _jsx(EntryBody, { change: entry, rowId: rowId, baseVersion: baseVersion, canRestore: canRestore, busy: busy, onRestore: onRestore }) }, entry.locale ?? '__base__'));
|
|
218
|
-
}) }));
|
|
219
|
-
}
|
|
220
|
-
/** The descriptor lane + value area, one grid row (EC-317). Zebra alternates
|
|
221
|
-
* by the row's position across the WHOLE change-list, not per field group. */
|
|
222
|
-
function FieldRow({ descriptor, restore, zebra, children, }) {
|
|
223
|
-
return (_jsxs("div", { className: cn(DIFF_ROW_GRID, 'rounded-sm px-2 py-2.5', zebra && 'bg-muted/40'), children: [_jsx("div", { className: "min-w-0", children: descriptor }), _jsx("div", { className: "min-w-0", children: children }), _jsx("div", { className: "flex justify-end", children: restore })] }));
|
|
224
|
-
}
|
|
225
|
-
/** The descriptor lane's content (EC-317): field label always, locale chip only
|
|
226
|
-
* when this row is a localized variant, plus the quiet added/removed kind tag. */
|
|
227
|
-
function FieldDescriptor({ label, locale, kind }) {
|
|
228
|
-
return (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 truncate", children: label }), _jsxs(Row, { gap: "1", wrap: true, children: [locale ? (_jsx(Badge, { variant: "outline", className: "px-1 py-0 text-[0.6rem] uppercase tracking-wide text-muted-foreground", children: localeChipLabel(locale) })) : null, _jsx(KindTag, { kind: kind })] })] }));
|
|
229
|
-
}
|
|
230
|
-
function EntryBody({ change, rowId, baseVersion, canRestore, busy, onRestore, }) {
|
|
231
|
-
const localeScope = change.locale !== undefined ? { locale: change.locale } : {};
|
|
232
|
-
if (change.blocks) {
|
|
233
|
-
return (_jsx(Stack, { gap: "2", className: "mt-1", children: change.blocks.map((block) => (_jsx(BlockSection, { block: block, rowId: `${rowId}|block:${block.blockId}`, baseVersion: baseVersion, canRestore: canRestore, busy: busy, onRestore: () => onRestore({ kind: 'block', field: change.field, blockId: block.blockId, ...localeScope }) }, block.blockId))) }));
|
|
234
|
-
}
|
|
235
|
-
if (change.type === 'richText') {
|
|
236
|
-
const before = richTextToText(change.before);
|
|
237
|
-
const after = richTextToText(change.after);
|
|
238
|
-
if (before === after)
|
|
239
|
-
return (_jsx(MutedNote, { children: before ? translate('cms.version.textUnchanged') : translate('cms.version.contentChanged') }));
|
|
240
|
-
return _jsx(WordDiffCols, { id: rowId, before: before, after: after });
|
|
241
|
-
}
|
|
242
|
-
if (change.kind === 'changed' && typeof change.before === 'string' && typeof change.after === 'string') {
|
|
243
|
-
return _jsx(WordDiffCols, { id: rowId, before: change.before, after: change.after });
|
|
244
|
-
}
|
|
245
|
-
return (_jsx(DiffColumns, { kind: change.kind, before: _jsx(SideValue, { value: change.before, other: change.after, typeHint: change.type, id: `${rowId}|before` }), after: _jsx(SideValue, { value: change.after, other: change.before, typeHint: change.type, id: `${rowId}|after` }) }));
|
|
246
|
-
}
|
|
247
|
-
function BlockSection({ block, rowId, baseVersion, canRestore, busy, onRestore, }) {
|
|
248
|
-
const name = block.componentId.split('.').pop() ?? block.componentId;
|
|
249
|
-
// 0.0.39: each block is its own owned row — a header band (block name + id + kind,
|
|
250
|
-
// restore right-aligned) over the prop changes, indented under it with a left rail
|
|
251
|
-
// so the nesting (field → block → props) reads at a glance.
|
|
252
|
-
return (_jsxs("div", { className: "overflow-hidden rounded-md border border-border/60", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/50 bg-muted/30 px-2 py-1.5", children: [_jsx(Text, { size: "xs", weight: "semibold", className: "min-w-0 truncate", children: name }), _jsx(Code, { tone: "muted", children: block.componentId }), _jsx(KindTag, { kind: block.kind }), _jsx(Spacer, {}), canRestore ? (_jsx(RestoreIcon, { busy: busy, title: translate('cms.version.restoreBlock', { version: baseVersion }), onClick: onRestore })) : null] }), _jsxs("div", { className: "px-2 py-2", children: [block.props && block.props.length > 0 ? (_jsx(Stack, { gap: "3", className: "border-l-2 border-border/50 pl-3", children: block.props.map((prop) => (_jsx(PropRow, { prop: prop, rowId: `${rowId}|prop:${prop.name}` }, prop.name))) })) : null, block.slotsChanged ? _jsx(MutedNote, { children: translate('cms.version.nestedContentChanged') }) : null, block.kind !== 'changed' && !block.props?.length ? (_jsx(MutedNote, { children: block.kind === 'added' ? translate('cms.version.blockAdded') : translate('cms.version.blockRemoved') })) : null] })] }));
|
|
253
|
-
}
|
|
254
|
-
function PropRow({ prop, rowId }) {
|
|
255
|
-
const useWordDiff = prop.kind === 'changed' && typeof prop.before === 'string' && typeof prop.after === 'string';
|
|
256
|
-
return (_jsxs("div", { children: [_jsx(Text, { size: "xs", tone: "subtle", className: "mb-0.5 block uppercase tracking-wide", children: prettyName(prop.name) }), useWordDiff ? (_jsx(WordDiffCols, { id: rowId, before: String(prop.before), after: String(prop.after) })) : (_jsx(DiffColumns, { kind: prop.kind, before: _jsx(SideValue, { value: prop.before, other: prop.after, id: `${rowId}|before` }), after: _jsx(SideValue, { value: prop.after, other: prop.before, id: `${rowId}|after` }) }))] }));
|
|
257
|
-
}
|
|
258
|
-
/** One side of a non-text change — the value, or an "empty" placeholder. */
|
|
259
|
-
function SideValue({ value, other, typeHint, id, }) {
|
|
260
|
-
if (value === undefined || value === null || value === '') {
|
|
261
|
-
return (_jsx(Text, { size: "sm", tone: "subtle", className: "italic", children: translate('cms.version.emptyValue') }));
|
|
262
|
-
}
|
|
263
|
-
return _jsx(ValueView, { value: value, other: other, typeHint: typeHint, id: id });
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Read-only value display, dispatching on the field type where known and on the
|
|
267
|
-
* self-describing value shape otherwise (block props carry no schema) — assets as
|
|
268
|
-
* thumbnails, relations as titles, everything else as calm text (EC-317: the
|
|
269
|
-
* plain-text fallback truncates past the shared length threshold).
|
|
270
|
-
*/
|
|
271
|
-
function ValueView({ value, other, typeHint, id, }) {
|
|
272
|
-
// Called unconditionally up front (rules-of-hooks) even though only the plain-
|
|
273
|
-
// text fallback branch below needs it — the early returns above it don't hook.
|
|
274
|
-
const { isExpanded } = useDiffDisplay();
|
|
275
|
-
if (typeHint === 'asset' || isAssetNode(value))
|
|
276
|
-
return _jsx(AssetValue, { value: value });
|
|
277
|
-
if (typeHint === 'relation' || readDocumentRef(value))
|
|
278
|
-
return _jsx(RelationValue, { value: value, other: other });
|
|
279
|
-
if (typeHint === 'richText')
|
|
280
|
-
return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: richTextToText(value) || translate('cms.version.richText') }));
|
|
281
|
-
if (typeHint === 'boolean' || typeof value === 'boolean')
|
|
282
|
-
return (_jsx(Text, { size: "sm", mono: true, children: value === true ? translate('common.yes') : translate('common.no') }));
|
|
283
|
-
if (typeHint === 'date')
|
|
284
|
-
return (_jsx(Text, { size: "sm", mono: true, children: String(value).slice(0, 10) }));
|
|
285
|
-
if (typeof value === 'object')
|
|
286
|
-
return (_jsx(Text, { size: "sm", tone: "muted", children: Array.isArray(value)
|
|
287
|
-
? translate(value.length === 1 ? 'cms.version.itemCount.one' : 'cms.version.itemCount.other', {
|
|
288
|
-
count: value.length,
|
|
289
|
-
})
|
|
290
|
-
: translate('cms.version.objectValue') }));
|
|
291
|
-
const expanded = isExpanded(id);
|
|
292
|
-
const { shown, isLong } = truncateText(String(value), expanded);
|
|
293
|
-
return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: shown }) }));
|
|
294
|
-
}
|
|
295
|
-
function RelationValue({ value, other }) {
|
|
296
|
-
const source = useCollectionView();
|
|
297
|
-
const refs = toRefArray(value);
|
|
298
|
-
if (refs.length === 0)
|
|
299
|
-
return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
|
|
300
|
-
const otherRefs = other !== undefined ? toRefArray(other) : [];
|
|
301
|
-
// Disambiguate a same-titled but different target (the "Ada → Ada" trap): show
|
|
302
|
-
// the id only when the titles would otherwise read identically.
|
|
303
|
-
const label = refs
|
|
304
|
-
.map((ref) => {
|
|
305
|
-
const title = source.titleOf(ref);
|
|
306
|
-
const collision = otherRefs.some((o) => o.id !== ref.id && source.titleOf(o) === title);
|
|
307
|
-
return collision ? `${title} · ${ref.id}` : title;
|
|
308
|
-
})
|
|
309
|
-
.join(', ');
|
|
310
|
-
return _jsx(Text, { size: "sm", children: label });
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Asset-record-owned keys — resolved off the asset RECORD at render (url, alt,
|
|
314
|
-
* dimensions, focal point, …), never authored on the referencing document. Mirrors
|
|
315
|
-
* the diff engine's own denylist (`document-diff.ts`): those keys never register as a
|
|
316
|
-
* change, so they are never worth SHOWING as one either. Only authored per-use
|
|
317
|
-
* sub-fields (EC-301, e.g. a placement's `caption`) surface beneath the thumbnail.
|
|
318
|
-
*/
|
|
319
|
-
const ASSET_RECORD_KEYS = new Set([
|
|
320
|
-
'url', 'alt', 'width', 'height', 'focalPoint', 'contentType', 'mimeType',
|
|
321
|
-
'byteSize', 'storageKey', 'title', 'filename', 'meta', 'createdAt', 'updatedAt',
|
|
322
|
-
]);
|
|
323
|
-
function authoredAssetSubfields(value) {
|
|
324
|
-
return Object.entries(assetSubfieldsOf(value)).filter(([key]) => !ASSET_RECORD_KEYS.has(key));
|
|
325
|
-
}
|
|
326
|
-
function AssetValue({ value }) {
|
|
327
|
-
const items = (Array.isArray(value) ? value : [value])
|
|
328
|
-
.map((entry) => ({ id: readAssetId(entry), sub: authoredAssetSubfields(entry) }))
|
|
329
|
-
.filter((a) => a.id !== undefined);
|
|
330
|
-
if (items.length === 0)
|
|
331
|
-
return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: String(value) }));
|
|
332
|
-
return (_jsx("div", { className: "flex flex-wrap gap-2", children: items.map(({ id, sub }, i) => (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(AssetThumb, { id: id }), sub.length > 0 ? (_jsx(Stack, { gap: "1", className: "pl-0.5", children: sub.map(([key, val]) => (_jsxs(Text, { size: "xs", tone: "muted", className: "break-words", children: [_jsxs("span", { className: "uppercase tracking-wide text-muted-foreground/70", children: [prettyName(key), ":"] }), ' ', typeof val === 'object' && val !== null
|
|
333
|
-
? translate('cms.version.objectValue')
|
|
334
|
-
: String(val)] }, key))) })) : null] }, `${id}-${i}`))) }));
|
|
335
|
-
}
|
|
336
|
-
/** An asset as a thumbnail (image with a resolvable URL) or a labelled badge —
|
|
337
|
-
* the visual diff for a cover-image change, not two identical filenames. */
|
|
338
|
-
function AssetThumb({ id }) {
|
|
339
|
-
const source = useCollectionView();
|
|
340
|
-
const assetCollectionId = source.assetCollectionId ?? 'asset';
|
|
341
|
-
const record = source.resolveAsset?.(id);
|
|
342
|
-
const label = record?.title ?? record?.filename ?? source.titleOf({ collection: assetCollectionId, id });
|
|
343
|
-
const isImage = record?.contentType?.startsWith('image/') ?? false;
|
|
344
|
-
const url = record?.url;
|
|
345
|
-
if (isImage && url && url.startsWith('http')) {
|
|
346
|
-
return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-2", children: [_jsx("img", { src: url, alt: record?.alt ?? label, loading: "lazy", className: "size-12 shrink-0 rounded border border-border bg-muted object-cover" }), _jsx(Text, { size: "xs", tone: "muted", className: "min-w-0 truncate", children: label })] }));
|
|
347
|
-
}
|
|
348
|
-
return (_jsxs(Badge, { variant: "outline", children: [_jsx(ImageIcon, { className: "size-3" }), " ", label] }));
|
|
349
|
-
}
|
|
350
|
-
// --- Prose word diff --------------------------------------------------------------
|
|
351
|
-
/**
|
|
352
|
-
* Prose diff across a row (EC-317): side-by-side reads as two word-diffed
|
|
353
|
-
* columns; unified merges into ONE reading lane (`mergeWordDiff`). Either way,
|
|
354
|
-
* a value past the length threshold collapses behind the row's expand control
|
|
355
|
-
* — the full `wordDiff`/`mergeWordDiff` computation underneath is untouched.
|
|
356
|
-
*/
|
|
357
|
-
function WordDiffCols({ id, before, after }) {
|
|
358
|
-
const { mode, isExpanded } = useDiffDisplay();
|
|
359
|
-
const expanded = isExpanded(id);
|
|
360
|
-
const wd = useMemo(() => wordDiff(before, after), [before, after]);
|
|
361
|
-
const merged = useMemo(() => mergeWordDiff(wd.before, wd.after), [wd]);
|
|
362
|
-
if (mode === 'unified') {
|
|
363
|
-
const { shown, isLong } = truncateTokens(merged, expanded, DIFF_TRUNCATE_THRESHOLD);
|
|
364
|
-
return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(UnifiedWordTokens, { tokens: shown }) }));
|
|
365
|
-
}
|
|
366
|
-
const beforeSlice = truncateTokens(wd.before, expanded, DIFF_TRUNCATE_THRESHOLD);
|
|
367
|
-
const afterSlice = truncateTokens(wd.after, expanded, DIFF_TRUNCATE_THRESHOLD);
|
|
368
|
-
const isLong = beforeSlice.isLong || afterSlice.isLong;
|
|
369
|
-
return (_jsx(ExpandToggle, { id: id, isLong: isLong, expanded: expanded, children: _jsx(TwoCol, { before: _jsx(WordTokens, { tokens: beforeSlice.shown, side: "before" }), after: _jsx(WordTokens, { tokens: afterSlice.shown, side: "after" }) }) }));
|
|
370
|
-
}
|
|
371
|
-
function WordTokens({ tokens, side }) {
|
|
372
|
-
return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words leading-relaxed", children: tokens.map((token, i) => token.changed ? (_jsx("mark", { className: cn('rounded-sm px-0.5', side === 'before'
|
|
373
|
-
? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
|
|
374
|
-
: 'bg-success/20 text-foreground'), children: token.text }, i)) : (_jsx("span", { className: "text-muted-foreground", children: token.text }, i))) }));
|
|
375
|
-
}
|
|
376
|
-
/** The unified lane's merged token stream — same marks as `WordTokens`, one
|
|
377
|
-
* continuous reading order instead of two columns. */
|
|
378
|
-
function UnifiedWordTokens({ tokens }) {
|
|
379
|
-
return (_jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words leading-relaxed", children: tokens.map((token, i) => token.kind === 'same' ? (_jsx("span", { className: "text-muted-foreground", children: token.text }, i)) : (_jsx("mark", { className: cn('rounded-sm px-0.5', token.kind === 'removed'
|
|
380
|
-
? 'bg-destructive/15 text-destructive line-through decoration-destructive/40'
|
|
381
|
-
: 'bg-success/20 text-foreground'), children: token.text }, i))) }));
|
|
382
|
-
}
|
|
383
|
-
// --- Small shared bits ------------------------------------------------------------
|
|
384
|
-
/** A quiet "· new" / "· removed" tag; nothing for a plain change (implicit). */
|
|
385
|
-
function KindTag({ kind }) {
|
|
386
|
-
if (kind === 'changed')
|
|
387
|
-
return null;
|
|
388
|
-
return (_jsxs(Text, { size: "xs", tone: "subtle", children: ["\u00B7 ", kind === 'added' ? translate('cms.version.kindNew') : translate('cms.version.kindRemoved')] }));
|
|
389
|
-
}
|
|
390
|
-
function RestoreIcon({ busy, title, onClick }) {
|
|
391
|
-
return (_jsx(Button, { variant: "ghost", size: "icon-sm", disabled: busy, title: title, "aria-label": title, onClick: onClick, children: _jsx(RotateCcw, {}) }));
|
|
392
|
-
}
|
|
393
|
-
function MutedNote({ children }) {
|
|
394
|
-
return (_jsx(Text, { size: "sm", tone: "muted", className: "italic", children: children }));
|
|
395
|
-
}
|
|
396
93
|
function CompareNotice({ children }) {
|
|
397
94
|
return (_jsx("div", { className: "mx-auto w-full max-w-5xl p-4 sm:p-6", children: _jsx("div", { className: "rounded-md border border-dashed border-border/70 px-4 py-8 text-center", children: _jsx(Text, { size: "sm", tone: "muted", children: children }) }) }));
|
|
398
95
|
}
|
|
399
|
-
function fieldScope(change) {
|
|
400
|
-
return { kind: 'field', field: change.field, ...(change.locale !== undefined ? { locale: change.locale } : {}) };
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Group changes BY FIELD (a localized field's per-locale variants together),
|
|
404
|
-
* fields in schema order, locales in project order (base bucket first).
|
|
405
|
-
*/
|
|
406
|
-
function groupByField(changes, fieldOrder, localeRank) {
|
|
407
|
-
const map = new Map();
|
|
408
|
-
for (const change of changes) {
|
|
409
|
-
let group = map.get(change.field);
|
|
410
|
-
if (!group) {
|
|
411
|
-
group = { field: change.field, label: change.label, type: change.type, entries: [] };
|
|
412
|
-
map.set(change.field, group);
|
|
413
|
-
}
|
|
414
|
-
group.entries.push(change);
|
|
415
|
-
}
|
|
416
|
-
const groups = [...map.values()];
|
|
417
|
-
groups.sort((a, b) => (fieldOrder.get(a.field) ?? Number.MAX_SAFE_INTEGER) - (fieldOrder.get(b.field) ?? Number.MAX_SAFE_INTEGER));
|
|
418
|
-
for (const group of groups) {
|
|
419
|
-
group.entries.sort((a, b) => localeCompare(a.locale, b.locale, localeRank));
|
|
420
|
-
}
|
|
421
|
-
return groups;
|
|
422
|
-
}
|
|
423
|
-
function localeCompare(a, b, rank) {
|
|
424
|
-
if (a === b)
|
|
425
|
-
return 0;
|
|
426
|
-
if (a === undefined)
|
|
427
|
-
return -1; // base bucket first
|
|
428
|
-
if (b === undefined)
|
|
429
|
-
return 1;
|
|
430
|
-
return (rank.get(a) ?? 99) - (rank.get(b) ?? 99);
|
|
431
|
-
}
|
|
432
|
-
/** camelCase / snake prop name → a readable label. */
|
|
433
|
-
function prettyName(name) {
|
|
434
|
-
const spaced = name.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
435
|
-
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
436
|
-
}
|
|
437
96
|
//# sourceMappingURL=version-compare.js.map
|