@prenta/admin 0.93.0 → 0.94.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 (90) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
  3. package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/version-diff.test.js +230 -0
  5. package/dist/__tests__/lib/version-diff.test.js.map +1 -0
  6. package/dist/__tests__/lib/version-history.test.d.ts +2 -0
  7. package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/version-history.test.js +125 -0
  9. package/dist/__tests__/lib/version-history.test.js.map +1 -0
  10. package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
  11. package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
  12. package/dist/__tests__/styles/responsive-layer.test.js +127 -0
  13. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
  14. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
  17. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
  18. package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
  19. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  20. package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
  21. package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
  22. package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
  23. package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
  24. package/dist/components/EditorRail.js +3 -3
  25. package/dist/components/EditorRail.js.map +1 -1
  26. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
  27. package/dist/components/page-tab/DirectiveSwitch.js +5 -1
  28. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
  29. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  30. package/dist/layout/primitives/AdminShell.js +2 -2
  31. package/dist/layout/primitives/AdminShell.js.map +1 -1
  32. package/dist/lib/version-diff.d.ts +50 -0
  33. package/dist/lib/version-diff.d.ts.map +1 -0
  34. package/dist/lib/version-diff.js +294 -0
  35. package/dist/lib/version-diff.js.map +1 -0
  36. package/dist/lib/version-history.d.ts +73 -0
  37. package/dist/lib/version-history.d.ts.map +1 -0
  38. package/dist/lib/version-history.js +126 -0
  39. package/dist/lib/version-history.js.map +1 -0
  40. package/dist/prenta-admin.css +1 -1
  41. package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
  42. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  43. package/dist/views/page-editor/EditorCanvas.js +3 -3
  44. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  45. package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
  46. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  47. package/dist/views/page-editor/EditorTopBar.js +8 -6
  48. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  49. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  50. package/dist/views/page-editor/PageSectionEditor.js +115 -32
  51. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  52. package/dist/views/page-editor/ReviewBar.d.ts +21 -0
  53. package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
  54. package/dist/views/page-editor/ReviewBar.js +22 -0
  55. package/dist/views/page-editor/ReviewBar.js.map +1 -0
  56. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  57. package/dist/views/page-editor/SectionInspector.js +1 -1
  58. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  59. package/dist/views/page-editor/SectionsPanel.js +1 -1
  60. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  61. package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
  62. package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
  63. package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
  64. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
  65. package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
  66. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  67. package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
  68. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/__tests__/lib/version-diff.test.ts +278 -0
  71. package/src/__tests__/lib/version-history.test.ts +166 -0
  72. package/src/__tests__/styles/responsive-layer.test.ts +152 -0
  73. package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
  74. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
  75. package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
  76. package/src/components/EditorRail.tsx +5 -5
  77. package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
  78. package/src/layout/primitives/AdminShell.tsx +13 -1
  79. package/src/lib/version-diff.ts +376 -0
  80. package/src/lib/version-history.ts +192 -0
  81. package/src/styles/responsive.css +281 -0
  82. package/src/styles/theme.css +8 -0
  83. package/src/views/page-editor/EditorCanvas.tsx +34 -7
  84. package/src/views/page-editor/EditorTopBar.tsx +20 -8
  85. package/src/views/page-editor/PageSectionEditor.tsx +162 -43
  86. package/src/views/page-editor/ReviewBar.tsx +76 -0
  87. package/src/views/page-editor/SectionInspector.tsx +2 -1
  88. package/src/views/page-editor/SectionsPanel.tsx +2 -2
  89. package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
  90. package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
@@ -0,0 +1,302 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { History, Loader2, RotateCcw, X } from 'lucide-react'
5
+ import {
6
+ diffVersions,
7
+ groupChangesBySection,
8
+ type FieldChange,
9
+ type SectionChangeGroup,
10
+ } from '../../lib/version-diff.js'
11
+ import {
12
+ groupVersionsByDay,
13
+ initials,
14
+ timeLabel,
15
+ versionKind,
16
+ type PageVersion,
17
+ } from '../../lib/version-history.js'
18
+
19
+ export interface VersionHistoryPanelProps {
20
+ versions: PageVersion[]
21
+ loading: boolean
22
+ error: string | null
23
+ /** Index into `versions`; 0 is the newest, which is "current". */
24
+ selectedIndex: number
25
+ onSelect: (index: number) => void
26
+ onClose: () => void
27
+ onRestore: (version: PageVersion) => void
28
+ restoring: boolean
29
+ canRestore: boolean
30
+ }
31
+
32
+ /**
33
+ * Version history, as a takeover of the editing rail.
34
+ *
35
+ * It is deliberately NOT a dialog. History here is about comparing *renders*:
36
+ * picking a version puts it on the canvas, and the changed sections light up
37
+ * there. A modal would cover the very thing it exists to let you look at, and
38
+ * a right-hand drawer would push the canvas around while you compare against
39
+ * it. Overlaying the rail leaves the canvas exactly where it was, at exactly
40
+ * the size it was, so the only thing that changes between two versions is the
41
+ * page itself.
42
+ *
43
+ * Absolutely positioned over the rail's own box (the rail is `relative`) so it
44
+ * inherits the user's chosen rail width and needs no width of its own.
45
+ */
46
+ export function VersionHistoryPanel({
47
+ versions,
48
+ loading,
49
+ error,
50
+ selectedIndex,
51
+ onSelect,
52
+ onClose,
53
+ onRestore,
54
+ restoring,
55
+ canRestore,
56
+ }: VersionHistoryPanelProps) {
57
+ /**
58
+ * Disclosure FOLLOWS selection rather than being tracked separately.
59
+ *
60
+ * They are the same act: the selected version is what the canvas renders,
61
+ * and the expanded diff is the list of what to look for on it. Held apart,
62
+ * the two drift — the parent can move the selection (the review bar's "Back
63
+ * to current" does exactly that), and the diff then describes one version
64
+ * while the canvas shows another, with Restore stranded on a row nobody can
65
+ * see. Only "did the user collapse the row they are on" needs remembering.
66
+ */
67
+ const [collapsed, setCollapsed] = useState(false)
68
+ const expandedId = collapsed ? null : (versions[selectedIndex]?.id ?? null)
69
+
70
+ const days = useMemo(() => groupVersionsByDay(versions), [versions])
71
+
72
+ // Diff each version against the one before it, so a row reports what that
73
+ // save DID rather than how it differs from the present.
74
+ const changesByVersion = useMemo(() => {
75
+ const map = new Map<string, FieldChange[]>()
76
+ versions.forEach((version, index) => {
77
+ map.set(version.id, diffVersions(version.sections, versions[index + 1]?.sections))
78
+ })
79
+ return map
80
+ }, [versions])
81
+
82
+ return (
83
+ <div
84
+ className="bg-card absolute inset-0 z-45 flex flex-col"
85
+ role="region"
86
+ aria-label="Version history"
87
+ >
88
+ <div className="border-border flex shrink-0 items-center gap-2 border-b py-2.5 pr-2.5 pl-3.5">
89
+ <History className="text-brand h-3.5 w-3.5" aria-hidden />
90
+ <h2 className="text-foreground flex-1 text-[12.5px] font-semibold">Version history</h2>
91
+ <span className="text-muted-foreground text-[11px] tabular-nums">
92
+ {versions.length} version{versions.length === 1 ? '' : 's'}
93
+ </span>
94
+ <button
95
+ type="button"
96
+ onClick={onClose}
97
+ aria-label="Close version history"
98
+ title="Close version history"
99
+ 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"
100
+ >
101
+ <X className="h-3.5 w-3.5" aria-hidden />
102
+ </button>
103
+ </div>
104
+
105
+ <div className="min-h-0 flex-1 overflow-y-auto">
106
+ {loading ? (
107
+ <div className="flex items-center justify-center py-16">
108
+ <Loader2 className="text-muted-foreground h-5 w-5 animate-spin" aria-hidden />
109
+ <span className="sr-only">Loading version history</span>
110
+ </div>
111
+ ) : error ? (
112
+ <p className="text-destructive px-3.5 py-10 text-center text-sm">{error}</p>
113
+ ) : versions.length === 0 ? (
114
+ <p className="text-muted-foreground px-3.5 py-10 text-center text-sm">
115
+ No versions yet. Every save adds one.
116
+ </p>
117
+ ) : (
118
+ days.map((day) => (
119
+ // Keyed on the group's first version, NOT on its label. Grouping
120
+ // walks consecutive runs, so an out-of-order page yields two
121
+ // non-adjacent groups both labelled "Today" — `groupVersionsByDay`
122
+ // has a test asserting exactly that — and a label key would collide.
123
+ <section key={day.versions[0]?.id ?? day.label}>
124
+ <h3 className="text-muted-foreground bg-muted border-border sticky top-0 z-2 border-b px-3.5 pt-2.5 pb-1.5 text-[10px] font-bold tracking-[0.7px] uppercase">
125
+ {day.label}
126
+ </h3>
127
+ {day.versions.map((version) => {
128
+ const index = versions.indexOf(version)
129
+ return (
130
+ <VersionRow
131
+ key={version.id}
132
+ version={version}
133
+ index={index}
134
+ changes={changesByVersion.get(version.id) ?? []}
135
+ selected={index === selectedIndex}
136
+ expanded={expandedId === version.id}
137
+ onToggle={() => {
138
+ // Re-clicking the row you are on collapses it; clicking
139
+ // any other selects it, which is also what opens it.
140
+ if (index === selectedIndex) setCollapsed((prev) => !prev)
141
+ else {
142
+ setCollapsed(false)
143
+ onSelect(index)
144
+ }
145
+ }}
146
+ onRestore={() => onRestore(version)}
147
+ restoring={restoring}
148
+ canRestore={canRestore}
149
+ />
150
+ )
151
+ })}
152
+ </section>
153
+ ))
154
+ )}
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ const TONE_CLASS: Record<string, string> = {
161
+ live: 'bg-success/15 text-success',
162
+ draft: 'bg-warning/15 text-warning',
163
+ neutral: 'bg-muted text-muted-foreground',
164
+ danger: 'bg-destructive/15 text-destructive',
165
+ }
166
+
167
+ function VersionRow({
168
+ version,
169
+ index,
170
+ changes,
171
+ selected,
172
+ expanded,
173
+ onToggle,
174
+ onRestore,
175
+ restoring,
176
+ canRestore,
177
+ }: {
178
+ version: PageVersion
179
+ index: number
180
+ changes: FieldChange[]
181
+ selected: boolean
182
+ expanded: boolean
183
+ onToggle: () => void
184
+ onRestore: () => void
185
+ restoring: boolean
186
+ canRestore: boolean
187
+ }) {
188
+ const kind = versionKind(version.changeType)
189
+ const isCurrent = index === 0
190
+ const groups = groupChangesBySection(changes)
191
+ const time = timeLabel(version.createdAt)
192
+
193
+ return (
194
+ <div className="border-border border-b">
195
+ <button
196
+ type="button"
197
+ onClick={onToggle}
198
+ aria-expanded={expanded}
199
+ className={`flex w-full items-center gap-2.5 py-2.5 pr-2.5 pl-3 text-left transition-colors ${
200
+ selected ? 'bg-accent shadow-[inset_2px_0_0_var(--acc)]' : 'hover:bg-accent/60'
201
+ }`}
202
+ >
203
+ <span
204
+ className="bg-muted text-muted-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9.5px] font-semibold"
205
+ aria-hidden
206
+ >
207
+ {initials(version.author.name)}
208
+ </span>
209
+
210
+ <span className="min-w-0 flex-1">
211
+ <span className="text-foreground flex items-center gap-1.5 text-xs font-semibold">
212
+ {time}
213
+ {isCurrent && (
214
+ <span className="bg-accent text-accent-foreground rounded px-1.5 py-px text-[8.5px] font-bold tracking-[0.4px] uppercase">
215
+ Current
216
+ </span>
217
+ )}
218
+ </span>
219
+ <span className="text-muted-foreground mt-px block truncate text-[10.5px]">
220
+ {version.author.name} ·{' '}
221
+ {changes.length
222
+ ? `${changes.length} change${changes.length === 1 ? '' : 's'}`
223
+ : 'no content changes'}
224
+ </span>
225
+ </span>
226
+
227
+ <span
228
+ className={`shrink-0 rounded px-1.5 py-px text-[8.5px] font-bold tracking-[0.4px] uppercase ${
229
+ TONE_CLASS[kind.tone] ?? TONE_CLASS.neutral
230
+ }`}
231
+ >
232
+ {kind.label}
233
+ </span>
234
+ </button>
235
+
236
+ {/*
237
+ * Opens on `expanded` alone — NOT on `expanded && changes.length`.
238
+ *
239
+ * Restore lives in here, and a version can legitimately have no computed
240
+ * diff: the oldest one held has no predecessor to compare against, and a
241
+ * metadata-only save changes no section. Gating the whole block on a
242
+ * non-empty diff makes exactly those versions reviewable but not
243
+ * restorable, which is the wrong way round — the oldest version is the
244
+ * one a rollback most often wants.
245
+ */}
246
+ {expanded && (
247
+ <div className="flex flex-col gap-2.5 pt-0 pr-3 pb-3 pl-[38px]">
248
+ {groups.length > 0 ? (
249
+ groups.map((group) => <ChangeGroup key={group.sectionId} group={group} />)
250
+ ) : (
251
+ <p className="text-muted-foreground text-[11px] leading-[1.45]">
252
+ No section changes to show against the version before it.
253
+ </p>
254
+ )}
255
+
256
+ {/* Restore is offered only on the row being reviewed, and never on the
257
+ newest one — "restore the version you are already looking at" is a
258
+ no-op dressed up as an action. */}
259
+ {selected && !isCurrent && canRestore && (
260
+ <button
261
+ type="button"
262
+ onClick={onRestore}
263
+ disabled={restoring}
264
+ className="border-border text-foreground hover:bg-accent inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
265
+ >
266
+ {restoring ? (
267
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
268
+ ) : (
269
+ <RotateCcw className="h-3 w-3" aria-hidden />
270
+ )}
271
+ Restore this version
272
+ </button>
273
+ )}
274
+ </div>
275
+ )}
276
+ </div>
277
+ )
278
+ }
279
+
280
+ function ChangeGroup({ group }: { group: SectionChangeGroup }) {
281
+ return (
282
+ <div>
283
+ <h4 className="text-foreground/80 mb-1 text-[9.5px] font-bold tracking-[0.4px] uppercase">
284
+ {group.sectionName}
285
+ </h4>
286
+ {group.changes.map((change, i) => (
287
+ <div key={`${change.field}-${i}`} className={i > 0 ? 'mt-1.5' : undefined}>
288
+ <div className="text-muted-foreground text-[9.5px] font-bold tracking-[0.3px]">
289
+ {change.field}
290
+ </div>
291
+ {/* Colour is not the only carrier: the old value is struck through
292
+ and the pair is ordered old-then-new, both of which survive a
293
+ reader who cannot separate red from green. */}
294
+ <div className="text-destructive text-[11px] leading-[1.45] line-through decoration-1">
295
+ {change.from}
296
+ </div>
297
+ <div className="text-success text-[11px] leading-[1.45]">{change.to}</div>
298
+ </div>
299
+ ))}
300
+ </div>
301
+ )
302
+ }
@@ -164,6 +164,18 @@ export interface SectionRendererProps {
164
164
  registerRef?: (id: string, el: HTMLElement | null) => void
165
165
  /** Live post data for post-oriented section renderers (pages omit this). */
166
166
  context?: PostRenderContext
167
+ /**
168
+ * Reviewing a past version: the canvas is a read-only render of a snapshot.
169
+ *
170
+ * A SEPARATE flag from `editable`, not a second use of it. Both modes
171
+ * suppress click-to-select and the hover outline, so it is tempting to fold
172
+ * them together — but review mode has to keep showing a label on the
173
+ * sections that changed, which is the entire reason it exists. One shared
174
+ * suppression rule hides exactly the thing the mode is for.
175
+ */
176
+ reviewing?: boolean
177
+ /** This section differs from the version immediately before the one shown. */
178
+ changed?: boolean
167
179
  }
168
180
 
169
181
  /**
@@ -179,41 +191,70 @@ export function SectionRenderer({
179
191
  onSelect,
180
192
  registerRef,
181
193
  context,
194
+ reviewing = false,
195
+ changed = false,
182
196
  }: SectionRendererProps) {
183
197
  const settings = section.settings ?? {}
184
- const hidden = editable && !section.visible
198
+ // A snapshot's hidden sections stay muted: the page is still being shown as
199
+ // the editor's own view of it, and a hidden section that rendered solid here
200
+ // would misreport what was live at that moment.
201
+ const chromed = editable || reviewing
202
+ const hidden = chromed && !section.visible
203
+ // Review mode is read-only. Click-to-select would edit the CURRENT page
204
+ // while the canvas shows a past one.
205
+ const interactive = editable && !reviewing
185
206
 
186
207
  return (
187
208
  <section
188
209
  ref={(el) => registerRef?.(section.id, el)}
189
210
  data-section-id={section.id}
190
- data-editable={editable ? 'true' : undefined}
191
- data-selected={selected ? 'true' : undefined}
211
+ data-editable={interactive ? 'true' : undefined}
212
+ data-selected={interactive && selected ? 'true' : undefined}
213
+ data-reviewing={reviewing ? 'true' : undefined}
214
+ data-changed={reviewing && changed ? 'true' : undefined}
192
215
  id={settings.anchorId || undefined}
193
216
  className={`relative ${backgroundClass(settings)} ${paddingClass(settings)} ${
194
217
  settings.cssClass ?? ''
195
218
  } ${hidden ? 'opacity-40' : ''} ${
196
- editable
219
+ interactive
197
220
  ? 'cursor-pointer outline-2 -outline-offset-2 motion-safe:transition-[outline-color] motion-safe:duration-150'
198
221
  : ''
199
222
  } ${
200
- selected
201
- ? 'outline outline-violet-500'
202
- : editable
203
- ? 'outline-transparent hover:outline hover:outline-[color-mix(in_oklab,var(--acc)_40%,transparent)]'
223
+ reviewing
224
+ ? changed
225
+ ? 'outline-warning outline-2 -outline-offset-2 outline-dashed'
204
226
  : 'outline-transparent'
227
+ : selected
228
+ ? 'outline outline-violet-500'
229
+ : interactive
230
+ ? 'outline-transparent hover:outline hover:outline-[color-mix(in_oklab,var(--acc)_40%,transparent)]'
231
+ : 'outline-transparent'
205
232
  }`}
206
- onClick={editable ? () => onSelect?.(section.id) : undefined}
233
+ onClick={interactive ? () => onSelect?.(section.id) : undefined}
207
234
  // Selection is a convenience in the editor; the section list is the
208
235
  // primary, fully-accessible control surface, so this stays a region.
209
- aria-label={editable ? `${section.name} section` : undefined}
236
+ aria-label={
237
+ reviewing && changed
238
+ ? `${section.name} section — changed in this version`
239
+ : interactive
240
+ ? `${section.name} section`
241
+ : undefined
242
+ }
210
243
  >
211
- {editable && selected && (
244
+ {interactive && selected && (
212
245
  <span className="bg-brand pointer-events-none absolute top-0 left-0 z-10 rounded-br-md px-2 py-0.5 text-xs font-medium text-white">
213
246
  {section.name}
214
247
  </span>
215
248
  )}
216
- {editable && hidden && (
249
+ {/* Persistent, not hover-revealed: while reviewing, this label IS the
250
+ finding. Unchanged sections get no label at all, so the marked ones
251
+ are legible at a glance down a scaled-down page. */}
252
+ {reviewing && changed && (
253
+ <span className="bg-warning text-warning-foreground pointer-events-none absolute top-0 left-0 z-10 rounded-br-md px-2 py-0.5 text-xs font-medium">
254
+ {section.name} — changed
255
+ </span>
256
+ )}
257
+ {chromed && hidden && (
217
258
  <span className="bg-foreground pointer-events-none absolute top-0 right-0 z-10 rounded-bl-md px-2 py-0.5 text-xs font-medium text-white">
218
259
  Hidden
219
260
  </span>