@prenta/admin 0.93.0 → 0.95.0

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 (130) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  4. package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
  5. package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
  6. package/dist/__tests__/lib/version-diff.test.js +230 -0
  7. package/dist/__tests__/lib/version-diff.test.js.map +1 -0
  8. package/dist/__tests__/lib/version-history.test.d.ts +2 -0
  9. package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/version-history.test.js +125 -0
  11. package/dist/__tests__/lib/version-history.test.js.map +1 -0
  12. package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
  13. package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
  14. package/dist/__tests__/styles/responsive-layer.test.js +127 -0
  15. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
  16. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
  19. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
  20. package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
  21. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  22. package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  25. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  26. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  27. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  28. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  29. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  30. package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
  31. package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
  32. package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
  33. package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
  34. package/dist/components/CanvasEditTip.js +1 -1
  35. package/dist/components/CanvasEditTip.js.map +1 -1
  36. package/dist/components/EditorRail.js +3 -3
  37. package/dist/components/EditorRail.js.map +1 -1
  38. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
  39. package/dist/components/page-tab/DirectiveSwitch.js +5 -1
  40. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
  41. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  42. package/dist/layout/primitives/AdminShell.js +2 -2
  43. package/dist/layout/primitives/AdminShell.js.map +1 -1
  44. package/dist/lib/version-diff.d.ts +50 -0
  45. package/dist/lib/version-diff.d.ts.map +1 -0
  46. package/dist/lib/version-diff.js +294 -0
  47. package/dist/lib/version-diff.js.map +1 -0
  48. package/dist/lib/version-history.d.ts +73 -0
  49. package/dist/lib/version-history.d.ts.map +1 -0
  50. package/dist/lib/version-history.js +126 -0
  51. package/dist/lib/version-history.js.map +1 -0
  52. package/dist/prenta-admin.css +1 -1
  53. package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
  54. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  55. package/dist/views/page-editor/EditorCanvas.js +3 -3
  56. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  57. package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
  58. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  59. package/dist/views/page-editor/EditorTopBar.js +8 -6
  60. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  62. package/dist/views/page-editor/PageSectionEditor.js +129 -40
  63. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  64. package/dist/views/page-editor/ReviewBar.d.ts +21 -0
  65. package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
  66. package/dist/views/page-editor/ReviewBar.js +22 -0
  67. package/dist/views/page-editor/ReviewBar.js.map +1 -0
  68. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  69. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  70. package/dist/views/page-editor/SectionEditForm.js +48 -0
  71. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  72. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  73. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  74. package/dist/views/page-editor/SectionInspector.js +6 -224
  75. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  76. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  77. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  78. package/dist/views/page-editor/SectionsPanel.js +11 -5
  79. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  80. package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
  81. package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
  82. package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
  83. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
  84. package/dist/views/page-editor/section-fields.d.ts +43 -0
  85. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  86. package/dist/views/page-editor/section-fields.js +236 -0
  87. package/dist/views/page-editor/section-fields.js.map +1 -0
  88. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  89. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  90. package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
  91. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  92. package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
  93. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  94. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  95. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  97. package/dist/views/post-editor/PostSectionEditor.js +11 -9
  98. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  99. package/package.json +3 -3
  100. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  101. package/src/__tests__/lib/version-diff.test.ts +278 -0
  102. package/src/__tests__/lib/version-history.test.ts +166 -0
  103. package/src/__tests__/styles/responsive-layer.test.ts +152 -0
  104. package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
  105. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
  106. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  107. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  108. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  109. package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
  110. package/src/components/CanvasEditTip.tsx +3 -3
  111. package/src/components/EditorRail.tsx +5 -5
  112. package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
  113. package/src/layout/primitives/AdminShell.tsx +13 -1
  114. package/src/lib/version-diff.ts +376 -0
  115. package/src/lib/version-history.ts +192 -0
  116. package/src/styles/responsive.css +281 -0
  117. package/src/styles/theme.css +8 -0
  118. package/src/views/page-editor/EditorCanvas.tsx +34 -7
  119. package/src/views/page-editor/EditorTopBar.tsx +20 -8
  120. package/src/views/page-editor/PageSectionEditor.tsx +182 -65
  121. package/src/views/page-editor/ReviewBar.tsx +76 -0
  122. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  123. package/src/views/page-editor/SectionInspector.tsx +9 -553
  124. package/src/views/page-editor/SectionsPanel.tsx +72 -16
  125. package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
  126. package/src/views/page-editor/section-fields.tsx +563 -0
  127. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  128. package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
  129. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  130. package/src/views/post-editor/PostSectionEditor.tsx +18 -26
@@ -26,9 +26,9 @@ export function CanvasEditTip() {
26
26
  <p className="min-w-0 flex-1 leading-[1.5]">
27
27
  <span className="text-foreground font-medium">Editing tip</span>
28
28
  {' — '}
29
- Hover sections for a light outline, then click to select. Edit copy and images in the
30
- inspector. Use <span className="text-foreground font-medium">Site preview</span> to click
31
- text and images in place.
29
+ Click a section on the canvas, or in the list, to open its copy and images in the section
30
+ list. Use <span className="text-foreground font-medium">Site preview</span> to click text
31
+ and images in place.
32
32
  </p>
33
33
  <button
34
34
  type="button"
@@ -96,7 +96,7 @@ export function EditorRail({ collapsedLabel, children }: EditorRailProps) {
96
96
  if (!open) {
97
97
  return (
98
98
  <div
99
- className="bg-card border-border flex shrink-0 flex-col items-center gap-3 border-r py-3"
99
+ className="prenta-editor-rail-collapsed bg-card border-border flex shrink-0 flex-col items-center gap-3 border-r py-3"
100
100
  style={{ width: RAIL_COLLAPSED_WIDTH }}
101
101
  >
102
102
  <button
@@ -104,14 +104,14 @@ export function EditorRail({ collapsedLabel, children }: EditorRailProps) {
104
104
  onClick={() => setOpen(true)}
105
105
  aria-label={`Expand ${collapsedLabel} panel`}
106
106
  title={`Expand ${collapsedLabel} panel`}
107
- className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
107
+ className="prenta-touch-target text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
108
108
  >
109
109
  <PanelLeftOpen className="h-4 w-4" aria-hidden />
110
110
  </button>
111
111
  {/* Vertical label so the strip still says which panel is hidden. The
112
112
  button's accessible name already carries it, hence aria-hidden. */}
113
113
  <span
114
- className="text-muted-foreground text-[10px] font-bold tracking-[0.8px] whitespace-nowrap uppercase [writing-mode:vertical-rl]"
114
+ className="prenta-rail-label text-muted-foreground text-[10px] font-bold tracking-[0.8px] whitespace-nowrap uppercase [writing-mode:vertical-rl]"
115
115
  aria-hidden
116
116
  >
117
117
  {collapsedLabel}
@@ -123,7 +123,7 @@ export function EditorRail({ collapsedLabel, children }: EditorRailProps) {
123
123
  return (
124
124
  <div
125
125
  ref={railRef}
126
- className={`bg-card border-border relative flex shrink-0 flex-col border-r ${
126
+ className={`prenta-editor-rail bg-card border-border relative flex shrink-0 flex-col border-r ${
127
127
  // A width transition during a drag fights the pointer: the panel
128
128
  // chases the cursor a frame behind and the resize feels broken.
129
129
  dragging ? '' : 'motion-safe:transition-[width] motion-safe:duration-(--mo-slow)'
@@ -209,7 +209,7 @@ export function RailHeader({
209
209
  onClick={onCollapse}
210
210
  aria-label={`Collapse ${title} panel`}
211
211
  title={`Collapse ${title} panel`}
212
- className="text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
212
+ className="prenta-touch-target text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
213
213
  >
214
214
  <PanelLeftClose className="h-4 w-4" aria-hidden />
215
215
  </button>
@@ -74,7 +74,10 @@ export function DirectiveSwitch({
74
74
  onCheckedChange={onChange}
75
75
  disabled={disabled}
76
76
  aria-label={`${label} — currently ${token}`}
77
- className={`focus-visible:ring-ring relative h-[19px] w-[34px] shrink-0 rounded-full transition-colors duration-(--mo-base) focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 ${
77
+ // `prenta-touch-hit`, not `prenta-touch-target`: growing the track to
78
+ // 44px would strand the thumb, which is positioned against it. The
79
+ // hit area grows; the switch keeps its measured 34×19.
80
+ className={`prenta-touch-hit focus-visible:ring-ring relative h-[19px] w-[34px] shrink-0 rounded-full transition-colors duration-(--mo-base) focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 ${
78
81
  checked ? 'bg-success' : 'bg-destructive/55'
79
82
  }`}
80
83
  >
@@ -71,6 +71,16 @@ export function AdminShell({
71
71
  : { display: 'block', position: 'relative' }
72
72
  }
73
73
  >
74
+ {/*
75
+ * First focusable thing in the admin, so a keyboard user's very first
76
+ * Tab offers a jump past the sidebar's ~20 nav items. Off-screen until
77
+ * focused; styling lives in `responsive.css` because it has to survive
78
+ * a consumer whose Tailwind build does not scan this file.
79
+ */}
80
+ <a className="prenta-skip-link" href="#prenta-main">
81
+ Skip to content
82
+ </a>
83
+
74
84
  {!isDesktop && mobileSidebarOpen && (
75
85
  <div
76
86
  aria-hidden
@@ -111,7 +121,9 @@ export function AdminShell({
111
121
  >
112
122
  {header}
113
123
  {breadcrumbs}
114
- <main className="min-h-0 flex-1 overflow-y-auto">{children}</main>
124
+ <main id="prenta-main" className="min-h-0 flex-1 overflow-y-auto">
125
+ {children}
126
+ </main>
115
127
  </div>
116
128
  </div>
117
129
  )
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Field-level diff between two page snapshots.
3
+ *
4
+ * One of the three parts of the page-builder handoff specified as real logic
5
+ * rather than mockup ("the version diff — field-level, computed from full
6
+ * snapshots, and its output drives the changed-section outlines on the
7
+ * canvas"). Nothing here is display-only: the same result feeds the history
8
+ * panel's per-field list AND the set of sections the canvas outlines while
9
+ * reviewing, so the two can never disagree about what changed.
10
+ *
11
+ * Pure over `PageSection[]`, with no React and no fetching, because the diff
12
+ * is asked for in three different places (panel rows, canvas outlines, the
13
+ * review bar's count) and recomputing it from the same inputs has to be free
14
+ * of side effects.
15
+ */
16
+ import {
17
+ getSectionType,
18
+ resolveRepeaterField,
19
+ type SectionFieldDef,
20
+ } from '../views/page-editor/section-types.js'
21
+ import type { PageSection } from './page-editor-service.js'
22
+
23
+ /** One changed field on one section. */
24
+ export interface FieldChange {
25
+ sectionId: string
26
+ /** Editor-facing section name, captured so the panel needn't re-look it up. */
27
+ sectionName: string
28
+ /** Human label for the field ("Heading"), from the section registry. */
29
+ field: string
30
+ /** Formatted previous value; `—` when absent. */
31
+ from: string
32
+ /** Formatted new value; `—` when absent. */
33
+ to: string
34
+ }
35
+
36
+ /** Changes for one section, in the order the sections appear on the page. */
37
+ export interface SectionChangeGroup {
38
+ sectionId: string
39
+ sectionName: string
40
+ changes: FieldChange[]
41
+ }
42
+
43
+ /**
44
+ * Labels for the keys that are not content fields and so have no entry in the
45
+ * section registry. Visibility earns its place here: hiding a section is the
46
+ * one structural change a design-owned CMS lets an editor make, so a history
47
+ * that silently dropped it would be missing the only structural edit there is.
48
+ */
49
+ const META_LABELS: Record<string, string> = {
50
+ visible: 'Visibility',
51
+ name: 'Section name',
52
+ }
53
+
54
+ const SETTING_LABELS: Record<string, string> = {
55
+ backgroundType: 'Background type',
56
+ backgroundColor: 'Background colour',
57
+ paddingY: 'Vertical padding',
58
+ anchorId: 'Anchor id',
59
+ cssClass: 'CSS class',
60
+ }
61
+
62
+ const EMPTY = '—'
63
+
64
+ /**
65
+ * Label a content key through the section registry — the same definitions the
66
+ * inspector renders from, so the diff reads in the words the editor typed
67
+ * under. Falls back to the raw key for content on a custom or unmanaged type
68
+ * the registry does not describe: a key is uglier than a label but it is true,
69
+ * whereas omitting the row would hide a real change.
70
+ */
71
+ function contentFieldLabel(sectionType: string, key: string): string {
72
+ const def = getSectionType(sectionType)
73
+ const field = def?.fields.find((f: SectionFieldDef) => f.key === key)
74
+ return field?.label ?? key
75
+ }
76
+
77
+ /**
78
+ * The repeater shape behind a content key, or null when the field is not one.
79
+ *
80
+ * Goes through `resolveRepeaterField` rather than reading `field.fields`
81
+ * directly: `stats` and `gallery` are shorthand presets whose sub-fields are
82
+ * supplied by the registry, not spelled out on the definition, so reading the
83
+ * raw property finds nothing and the diff falls back to raw keys ("value"
84
+ * instead of "Value") on the two repeaters that ship by default.
85
+ */
86
+ function repeaterFor(sectionType: string, key: string) {
87
+ const def = getSectionType(sectionType)
88
+ const field = def?.fields.find((f: SectionFieldDef) => f.key === key)
89
+ return field ? resolveRepeaterField(field) : null
90
+ }
91
+
92
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
94
+ }
95
+
96
+ /**
97
+ * Values that all render as `—` and must therefore compare as equal.
98
+ *
99
+ * Without this, tightening the comparison from formatted strings to structure
100
+ * would start reporting `'' → undefined` as a change, emitting rows that read
101
+ * "— → —". Blank is blank however it is spelled.
102
+ */
103
+ function isBlank(value: unknown): boolean {
104
+ if (value === null || value === undefined) return true
105
+ if (typeof value === 'string') return value.trim() === ''
106
+ if (Array.isArray(value)) return value.length === 0
107
+ if (isPlainObject(value)) return Object.keys(value).length === 0
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Structural equality, which is what decides whether a field changed.
113
+ *
114
+ * This deliberately does NOT go through {@link formatValue}. Comparing the
115
+ * display strings is the obvious shortcut and it silently loses real edits:
116
+ * `formatValue` collapses an array to "N items", so retyping a stats row while
117
+ * keeping the row count formats identically on both sides and the field reports
118
+ * no change — no row in the panel, and no outline on the canvas. That is
119
+ * precisely the edit the reviewer is looking for.
120
+ */
121
+ function deepEqual(a: unknown, b: unknown): boolean {
122
+ if (isBlank(a) && isBlank(b)) return true
123
+ if (a === b) return true
124
+ if (Array.isArray(a) && Array.isArray(b)) {
125
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]))
126
+ }
127
+ if (isPlainObject(a) && isPlainObject(b)) {
128
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
129
+ for (const key of keys) if (!deepEqual(a[key], b[key])) return false
130
+ return true
131
+ }
132
+ return false
133
+ }
134
+
135
+ /**
136
+ * Render a value as the one line the diff shows.
137
+ *
138
+ * Lossy on purpose — a repeater row expanded in full would bury the change it
139
+ * is reporting. It is used for DISPLAY ONLY; {@link deepEqual} decides whether
140
+ * a field changed, and repeater rows are descended into by
141
+ * {@link compareRepeater} so a collapsed summary never has to stand in for a
142
+ * change it cannot show.
143
+ */
144
+ export function formatValue(value: unknown): string {
145
+ if (value === null || value === undefined) return EMPTY
146
+ if (typeof value === 'string') return value.trim() === '' ? EMPTY : value
147
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
148
+ if (typeof value === 'number') return String(value)
149
+ if (Array.isArray(value)) {
150
+ if (value.length === 0) return EMPTY
151
+ return `${value.length} item${value.length === 1 ? '' : 's'}`
152
+ }
153
+ if (typeof value === 'object') {
154
+ const record = value as Record<string, unknown>
155
+ for (const key of ['name', 'title', 'label', 'url', 'alt']) {
156
+ const candidate = record[key]
157
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
158
+ }
159
+ const count = Object.keys(record).length
160
+ return count === 0 ? EMPTY : `${count} field${count === 1 ? '' : 's'}`
161
+ }
162
+ return String(value)
163
+ }
164
+
165
+ /** Visibility reads as a state, not a boolean — "No" is not a useful diff. */
166
+ function formatVisible(value: unknown): string {
167
+ return value === false ? 'Hidden' : 'Shown'
168
+ }
169
+
170
+ function push(
171
+ out: FieldChange[],
172
+ section: Pick<PageSection, 'id' | 'name'>,
173
+ field: string,
174
+ from: string,
175
+ to: string,
176
+ ): void {
177
+ out.push({ sectionId: section.id, sectionName: section.name, field, from, to })
178
+ }
179
+
180
+ function compare(
181
+ out: FieldChange[],
182
+ section: Pick<PageSection, 'id' | 'name'>,
183
+ field: string,
184
+ before: unknown,
185
+ after: unknown,
186
+ format: (v: unknown) => string = formatValue,
187
+ ): void {
188
+ if (deepEqual(before, after)) return
189
+ push(out, section, field, format(before), format(after))
190
+ }
191
+
192
+ /**
193
+ * Diff a repeater (stats rows, gallery images, FAQ entries) row by row.
194
+ *
195
+ * A single "2 items → 2 items" row would be worse than useless: it is the one
196
+ * case where the summary cannot express the change, and the reviewer's next
197
+ * question — *which* row — has no answer. Descending gives
198
+ * "Stats · Row 2 · Value" and a real before/after.
199
+ */
200
+ function compareRepeater(
201
+ out: FieldChange[],
202
+ section: Pick<PageSection, 'id' | 'name'>,
203
+ sectionType: string,
204
+ key: string,
205
+ label: string,
206
+ before: readonly unknown[],
207
+ after: readonly unknown[],
208
+ ): void {
209
+ const repeater = repeaterFor(sectionType, key)
210
+ // The registry's own noun for a row ("Stat", "Image") when it has one.
211
+ const rowNoun = repeater?.rowLabel ?? 'Row'
212
+ const subLabel = (subKey: string) =>
213
+ repeater?.fields.find((f: SectionFieldDef) => f.key === subKey)?.label ?? subKey
214
+
215
+ for (let i = 0; i < Math.max(before.length, after.length); i++) {
216
+ const rowLabel = `${label} · ${rowNoun} ${i + 1}`
217
+ const prev = before[i]
218
+ const next = after[i]
219
+
220
+ if (prev === undefined) {
221
+ push(out, section, rowLabel, EMPTY, 'Added')
222
+ continue
223
+ }
224
+ if (next === undefined) {
225
+ push(out, section, rowLabel, 'Present', 'Removed')
226
+ continue
227
+ }
228
+ if (deepEqual(prev, next)) continue
229
+
230
+ // Rows are normally objects of sub-fields; anything else (a bare string in
231
+ // a legacy gallery, say) still gets a row rather than being skipped.
232
+ if (!isPlainObject(prev) || !isPlainObject(next)) {
233
+ push(out, section, rowLabel, formatValue(prev), formatValue(next))
234
+ continue
235
+ }
236
+
237
+ for (const subKey of new Set([...Object.keys(prev), ...Object.keys(next)])) {
238
+ compare(out, section, `${rowLabel} · ${subLabel(subKey)}`, prev[subKey], next[subKey])
239
+ }
240
+ }
241
+ }
242
+
243
+ /** One content field: descends into repeaters, otherwise a plain comparison. */
244
+ function compareContentField(
245
+ out: FieldChange[],
246
+ section: Pick<PageSection, 'id' | 'name'>,
247
+ sectionType: string,
248
+ key: string,
249
+ before: unknown,
250
+ after: unknown,
251
+ ): void {
252
+ if (deepEqual(before, after)) return
253
+
254
+ const label = contentFieldLabel(sectionType, key)
255
+ const beforeRows = Array.isArray(before) ? before : null
256
+ const afterRows = Array.isArray(after) ? after : null
257
+
258
+ if (beforeRows && afterRows) {
259
+ compareRepeater(out, section, sectionType, key, label, beforeRows, afterRows)
260
+ return
261
+ }
262
+
263
+ push(out, section, label, formatValue(before), formatValue(after))
264
+ }
265
+
266
+ /**
267
+ * Compute what changed going FROM `older` TO `newer`.
268
+ *
269
+ * Diffing version *i* against version *i+1* therefore yields what changed *in*
270
+ * version *i* — the framing the history panel needs, where each row answers
271
+ * "what did this save do?" rather than "how does this differ from now?".
272
+ *
273
+ * `older` is optional because the earliest version in a list has nothing
274
+ * before it. That case is no changes, not "everything was added": the oldest
275
+ * snapshot we happen to hold is not the moment the page came into existence,
276
+ * and reporting every field as new would say it was.
277
+ */
278
+ export function diffVersions(
279
+ newer: readonly PageSection[],
280
+ older: readonly PageSection[] | null | undefined,
281
+ ): FieldChange[] {
282
+ if (!older) return []
283
+
284
+ const out: FieldChange[] = []
285
+ const olderById = new Map(older.map((s) => [s.id, s]))
286
+
287
+ for (const next of newer) {
288
+ const prev = olderById.get(next.id)
289
+
290
+ if (!prev) {
291
+ out.push({
292
+ sectionId: next.id,
293
+ sectionName: next.name,
294
+ field: 'Section',
295
+ from: EMPTY,
296
+ to: 'Added',
297
+ })
298
+ continue
299
+ }
300
+
301
+ if (next.name !== prev.name) {
302
+ compare(out, next, META_LABELS.name!, prev.name, next.name)
303
+ }
304
+ compare(out, next, META_LABELS.visible!, prev.visible, next.visible, formatVisible)
305
+
306
+ // Union of both key sets: a field cleared to undefined only appears in the
307
+ // older snapshot, and clearing a heading is exactly the kind of edit a
308
+ // history is consulted about.
309
+ const contentKeys = new Set([
310
+ ...Object.keys(prev.content ?? {}),
311
+ ...Object.keys(next.content ?? {}),
312
+ ])
313
+ for (const key of contentKeys) {
314
+ compareContentField(
315
+ out,
316
+ next,
317
+ next.sectionType,
318
+ key,
319
+ prev.content?.[key],
320
+ next.content?.[key],
321
+ )
322
+ }
323
+
324
+ const settingKeys = new Set([
325
+ ...Object.keys(prev.settings ?? {}),
326
+ ...Object.keys(next.settings ?? {}),
327
+ ])
328
+ for (const key of settingKeys) {
329
+ compare(
330
+ out,
331
+ next,
332
+ SETTING_LABELS[key] ?? key,
333
+ (prev.settings as Record<string, unknown>)?.[key],
334
+ (next.settings as Record<string, unknown>)?.[key],
335
+ )
336
+ }
337
+ }
338
+
339
+ // Sections present before and gone after. Reported against the OLD section's
340
+ // name because the new snapshot has nothing to name it with.
341
+ const newerIds = new Set(newer.map((s) => s.id))
342
+ for (const gone of older) {
343
+ if (newerIds.has(gone.id)) continue
344
+ out.push({
345
+ sectionId: gone.id,
346
+ sectionName: gone.name,
347
+ field: 'Section',
348
+ from: 'Present',
349
+ to: 'Removed',
350
+ })
351
+ }
352
+
353
+ return out
354
+ }
355
+
356
+ /**
357
+ * Group changes by section, preserving first-seen order so the panel lists
358
+ * them top-to-bottom in the order they appear on the page.
359
+ */
360
+ export function groupChangesBySection(changes: readonly FieldChange[]): SectionChangeGroup[] {
361
+ const groups = new Map<string, SectionChangeGroup>()
362
+ for (const change of changes) {
363
+ let group = groups.get(change.sectionId)
364
+ if (!group) {
365
+ group = { sectionId: change.sectionId, sectionName: change.sectionName, changes: [] }
366
+ groups.set(change.sectionId, group)
367
+ }
368
+ group.changes.push(change)
369
+ }
370
+ return [...groups.values()]
371
+ }
372
+
373
+ /** The ids the canvas outlines while reviewing a version. */
374
+ export function changedSectionIds(changes: readonly FieldChange[]): Set<string> {
375
+ return new Set(changes.map((c) => c.sectionId))
376
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Version history data layer for the page editor.
3
+ *
4
+ * History in this editor is a **mode, not a popup**: selecting a version
5
+ * renders it on the canvas, so the panel needs the full section snapshot of
6
+ * every entry, not just its metadata. `GET /collections/:slug/:id/versions`
7
+ * already returns each version's whole `data` blob — the same blob the restore
8
+ * endpoint writes back — so the snapshots are read straight off the list
9
+ * response and no second request per row is needed.
10
+ */
11
+ import { cmsApi } from './api.js'
12
+ import { coerceSections, type PageSection } from '@prenta/core/page-sections'
13
+
14
+ /**
15
+ * Mirrors the `ChangeType` enum in the Prisma schema.
16
+ *
17
+ * `PUBLISH` and `RESTORE` were declared there from the beginning and nothing
18
+ * ever wrote them; `cms-core` now does. Rows written before that read `UPDATE`
19
+ * whatever they actually were, which is why {@link versionKind} labels
20
+ * `UPDATE` as "Saved" rather than "Draft" — the neutral reading is the only
21
+ * one that is true for both old and new rows.
22
+ */
23
+ export type VersionChangeType = 'CREATE' | 'UPDATE' | 'PUBLISH' | 'RESTORE' | 'DELETE'
24
+
25
+ export interface VersionAuthor {
26
+ id: string
27
+ /** Display name, falling back to email and then to a non-blank placeholder. */
28
+ name: string
29
+ }
30
+
31
+ export interface PageVersion {
32
+ id: string
33
+ changeType: VersionChangeType
34
+ createdAt: string
35
+ author: VersionAuthor
36
+ /** Full page content as of this version — what the canvas renders. */
37
+ sections: PageSection[]
38
+ }
39
+
40
+ /** Versions sharing a calendar day, newest day first. */
41
+ export interface VersionDayGroup {
42
+ /** `Today` / `Yesterday` / `Jul 27` — the sticky header text. */
43
+ label: string
44
+ versions: PageVersion[]
45
+ }
46
+
47
+ /** How one change type presents as a pill. */
48
+ export interface VersionKind {
49
+ label: string
50
+ /** Semantic tone; the panel maps these onto token classes. */
51
+ tone: 'live' | 'draft' | 'neutral' | 'danger'
52
+ }
53
+
54
+ const KINDS: Record<VersionChangeType, VersionKind> = {
55
+ // "Published" is the one distinction that matters in a version list: which
56
+ // of these could a visitor have seen?
57
+ PUBLISH: { label: 'Published', tone: 'live' },
58
+ CREATE: { label: 'Created', tone: 'neutral' },
59
+ // NOT "Draft". Every version written before cms-core started recording
60
+ // PUBLISH is an UPDATE regardless of whether it went live, so calling this
61
+ // "Draft" would state something false about historical rows. "Saved" is true
62
+ // of all of them.
63
+ UPDATE: { label: 'Saved', tone: 'draft' },
64
+ RESTORE: { label: 'Restored', tone: 'neutral' },
65
+ DELETE: { label: 'Deleted', tone: 'danger' },
66
+ }
67
+
68
+ export function versionKind(changeType: string): VersionKind {
69
+ return KINDS[changeType as VersionChangeType] ?? { label: 'Saved', tone: 'draft' }
70
+ }
71
+
72
+ interface VersionApiRow {
73
+ id: string
74
+ changeType: string
75
+ createdAt: string
76
+ data: Record<string, unknown> | null
77
+ changedBy?: { id: string; name?: string | null; email?: string | null } | null
78
+ }
79
+
80
+ function toAuthor(row: VersionApiRow): VersionAuthor {
81
+ const by = row.changedBy
82
+ return {
83
+ id: by?.id ?? '',
84
+ // An avatar built from an empty string renders a blank circle, so the
85
+ // fallback has to be a real word rather than ''.
86
+ name: by?.name?.trim() || by?.email?.trim() || 'Unknown',
87
+ }
88
+ }
89
+
90
+ export function toPageVersion(row: VersionApiRow): PageVersion {
91
+ const data = row.data ?? {}
92
+ return {
93
+ id: row.id,
94
+ changeType: (row.changeType as VersionChangeType) ?? 'UPDATE',
95
+ createdAt: row.createdAt,
96
+ author: toAuthor(row),
97
+ // `preserve` matches how the editor reads the live document. Coercing a
98
+ // snapshot more strictly than the current page would make an unmanaged
99
+ // section look like it was deleted in every version that contains one.
100
+ sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
101
+ }
102
+ }
103
+
104
+ /** Newest first, matching the API's own ordering. */
105
+ export async function fetchPageVersions(
106
+ collectionSlug: string,
107
+ documentId: string,
108
+ ): Promise<PageVersion[]> {
109
+ const res = await cmsApi<{ versions: VersionApiRow[]; total: number }>(
110
+ `/collections/${collectionSlug}/${documentId}/versions?pageSize=50`,
111
+ )
112
+ if (res.error) throw new Error(res.error)
113
+ const rows = res.data?.versions ?? []
114
+ return rows.map(toPageVersion)
115
+ }
116
+
117
+ export async function restorePageVersion(
118
+ collectionSlug: string,
119
+ documentId: string,
120
+ versionId: string,
121
+ ): Promise<void> {
122
+ const res = await cmsApi(
123
+ `/collections/${collectionSlug}/${documentId}/versions/${versionId}/restore`,
124
+ { method: 'POST' },
125
+ )
126
+ if (res.error) throw new Error(res.error)
127
+ }
128
+
129
+ // ─── Presentation helpers ────────────────────────────────────────────
130
+
131
+ function startOfDay(date: Date): number {
132
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
133
+ }
134
+
135
+ /**
136
+ * `Today` / `Yesterday` / a short date.
137
+ *
138
+ * `now` is a parameter rather than a `Date.now()` call so the label is
139
+ * testable — "relative to today" is untestable when today is whatever the
140
+ * suite happens to run on.
141
+ */
142
+ export function dayLabel(iso: string, now: Date = new Date()): string {
143
+ const date = new Date(iso)
144
+ if (Number.isNaN(date.getTime())) return 'Unknown date'
145
+
146
+ const days = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000)
147
+ if (days === 0) return 'Today'
148
+ if (days === 1) return 'Yesterday'
149
+
150
+ return date.toLocaleDateString(undefined, {
151
+ month: 'short',
152
+ day: 'numeric',
153
+ // Drop the year within the current one — "Jul 27, 2026" in a list where
154
+ // every entry is 2026 is four characters of noise per row.
155
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
156
+ })
157
+ }
158
+
159
+ export function timeLabel(iso: string): string {
160
+ const date = new Date(iso)
161
+ if (Number.isNaN(date.getTime())) return ''
162
+ return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
163
+ }
164
+
165
+ /**
166
+ * Group an already-ordered list into consecutive runs sharing a day label.
167
+ *
168
+ * Consecutive runs, not a keyed bucket: the list arrives newest-first and must
169
+ * stay that way, and a `Map` keyed on the label would silently merge two runs
170
+ * that are not adjacent if the API ever returned an unsorted page.
171
+ */
172
+ export function groupVersionsByDay(
173
+ versions: readonly PageVersion[],
174
+ now: Date = new Date(),
175
+ ): VersionDayGroup[] {
176
+ const groups: VersionDayGroup[] = []
177
+ for (const version of versions) {
178
+ const label = dayLabel(version.createdAt, now)
179
+ const last = groups[groups.length - 1]
180
+ if (last && last.label === label) last.versions.push(version)
181
+ else groups.push({ label, versions: [version] })
182
+ }
183
+ return groups
184
+ }
185
+
186
+ /** Two-letter monogram for the row avatar. */
187
+ export function initials(name: string): string {
188
+ const parts = name.trim().split(/\s+/).filter(Boolean)
189
+ if (parts.length === 0) return '?'
190
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()
191
+ return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase()
192
+ }