@elytracms/studio 0.0.45 → 0.0.46

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.
Files changed (58) hide show
  1. package/convex/cms.ts +1 -0
  2. package/convex/schema.ts +3 -0
  3. package/dist/components/ai/ai-chat-panel-lazy.d.ts +1 -0
  4. package/dist/components/ai/ai-chat-panel-lazy.js +21 -0
  5. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -0
  6. package/dist/components/ai/ai-chat-panel.d.ts +10 -0
  7. package/dist/components/ai/ai-chat-panel.js +202 -0
  8. package/dist/components/ai/ai-chat-panel.js.map +1 -0
  9. package/dist/components/ai/ai-cms-panel.d.ts +9 -0
  10. package/dist/components/ai/ai-cms-panel.js +24 -0
  11. package/dist/components/ai/ai-cms-panel.js.map +1 -0
  12. package/dist/components/ai/ai-panel-toggle.d.ts +8 -0
  13. package/dist/components/ai/ai-panel-toggle.js +27 -0
  14. package/dist/components/ai/ai-panel-toggle.js.map +1 -0
  15. package/dist/components/ai/chat-ui.d.ts +36 -0
  16. package/dist/components/ai/chat-ui.js +68 -0
  17. package/dist/components/ai/chat-ui.js.map +1 -0
  18. package/dist/components/ai/proposal-card.d.ts +15 -0
  19. package/dist/components/ai/proposal-card.js +42 -0
  20. package/dist/components/ai/proposal-card.js.map +1 -0
  21. package/dist/components/app-views/default-modules.js +2 -1
  22. package/dist/components/app-views/default-modules.js.map +1 -1
  23. package/dist/components/cms/document-diff-view.d.ts +41 -0
  24. package/dist/components/cms/document-diff-view.js +299 -0
  25. package/dist/components/cms/document-diff-view.js.map +1 -0
  26. package/dist/components/cms/version-compare.d.ts +7 -13
  27. package/dist/components/cms/version-compare.js +24 -365
  28. package/dist/components/cms/version-compare.js.map +1 -1
  29. package/dist/components/cms/versions-pane.js +1 -1
  30. package/dist/components/cms/versions-pane.js.map +1 -1
  31. package/dist/components/studio-shell.js +2 -1
  32. package/dist/components/studio-shell.js.map +1 -1
  33. package/dist/lib/ai/chat-state.d.ts +73 -0
  34. package/dist/lib/ai/chat-state.js +101 -0
  35. package/dist/lib/ai/chat-state.js.map +1 -0
  36. package/dist/lib/ai/create-flow.d.ts +116 -0
  37. package/dist/lib/ai/create-flow.js +194 -0
  38. package/dist/lib/ai/create-flow.js.map +1 -0
  39. package/dist/lib/ai/index.d.ts +4 -0
  40. package/dist/lib/ai/index.js +4 -0
  41. package/dist/lib/ai/index.js.map +1 -1
  42. package/dist/lib/ai/route-provider.d.ts +50 -0
  43. package/dist/lib/ai/route-provider.js +60 -0
  44. package/dist/lib/ai/route-provider.js.map +1 -0
  45. package/dist/lib/ai/route.d.ts +12 -0
  46. package/dist/lib/ai/route.js +42 -3
  47. package/dist/lib/ai/route.js.map +1 -1
  48. package/dist/lib/ai/tools.d.ts +65 -0
  49. package/dist/lib/ai/tools.js +63 -0
  50. package/dist/lib/ai/tools.js.map +1 -0
  51. package/dist/lib/ai-panel.d.ts +20 -0
  52. package/dist/lib/ai-panel.js +48 -0
  53. package/dist/lib/ai-panel.js.map +1 -0
  54. package/dist/lib/i18n/messages.en.js +34 -0
  55. package/dist/lib/i18n/messages.en.js.map +1 -1
  56. package/dist/lib/persistence/convex-adapter.js +2 -0
  57. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  58. package/package.json +2 -2
@@ -1,42 +1,29 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useCallback, useContext, useMemo, useState } from 'react';
3
- import { ArrowRight, ChevronDown, ChevronLeft, ChevronRight, Image as ImageIcon, RotateCcw } from 'lucide-react';
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 { assetSubfieldsOf, isAssetNode, readAssetId, readDocumentRef } from '@elytracms/core/cms-core';
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 { fieldLabel } from './cms-labels';
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. Renders as the sticky **Compare** content tab
21
- * (the Versions sidebar pane stays on as the navigator).
22
- *
23
- * A calm change-list grouped BY FIELD (a localized field's per-locale variants sit
24
- * together): a left **descriptor lane** (EC-317 — field label + locale) identifies
25
- * each row, next to its before | after value (or one merged **unified** lane —
26
- * a persisted transient view preference, EC-317). Text reads as a prose diff
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
- 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(Row, { gap: "2", align: "center", justify: "between", wrap: true, className: "px-0.5", children: [_jsx(DiffViewToggle, { mode: mode, onChange: setMode }), _jsx(Button, { variant: "ghost", size: "sm", onClick: toggleExpandAll, children: translate(expandAll ? 'cms.field.collapseAll' : 'cms.field.expandAll') })] })) : null, _jsxs(Text, { size: "xs", tone: "muted", className: "px-0.5", children: [diff.changes.length === 0
142
- ? translate('cms.version.noDifferences')
143
- : translate(diff.changes.length === 1 ? 'cms.version.changeSummary.one' : 'cms.version.changeSummary.other', {
144
- count: diff.changes.length,
145
- unchanged: diff.unchangedCount,
146
- }), !canRestore && diff.changes.length > 0 ? translate('cms.version.comparingPastVersionsHint') : ''] }), diff.changes.length === 0 ? (_jsx(CompareNotice, { children: translate('cms.version.versionsIdentical') })) : (_jsx(DiffDisplayContext.Provider, { value: diffDisplay, children: _jsxs("div", { className: "pb-1", children: [_jsxs("div", { className: cn(DIFF_ROW_GRID, 'px-2 pb-1.5'), children: [_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.fieldColumnHeader') }), mode === 'unified' ? (_jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.unifiedCaption', { base: compare.base, target: targetLabel }) })) : (_jsx(TwoCol, { before: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.beforeVersion', { version: compare.base }) }), after: _jsx(Text, { size: "xs", tone: "subtle", className: "uppercase tracking-wide", children: translate('cms.version.afterTarget', { target: targetLabel }) }) }))] }), groupRows.map(({ group, field, zebraStart }) => (_jsx(FieldGroup, { group: group, field: field, zebraStart: zebraStart, baseVersion: compare.base, canRestore: canRestore, busy: busy, onRestore: restore }, group.field)))] }) }))] }));
147
- }
148
- const DiffDisplayContext = createContext(null);
149
- function useDiffDisplay() {
150
- const ctx = useContext(DiffDisplayContext);
151
- if (!ctx)
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