@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
@@ -10,7 +10,14 @@ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
10
10
  import { EditorRail, RailHeader } from '../../components/EditorRail.js'
11
11
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
12
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
- import { VersionHistory } from '../../components/VersionHistory.js'
13
+ import { VersionHistoryPanel } from './VersionHistoryPanel.js'
14
+ import { ReviewBar } from './ReviewBar.js'
15
+ import {
16
+ fetchPageVersions,
17
+ restorePageVersion,
18
+ type PageVersion,
19
+ } from '../../lib/version-history.js'
20
+ import { changedSectionIds, diffVersions } from '../../lib/version-diff.js'
14
21
  import {
15
22
  emptyPage,
16
23
  fetchPageForEditor,
@@ -22,7 +29,6 @@ import {
22
29
  updateSectionContent,
23
30
  updateSectionContentAtPath,
24
31
  updateSectionMeta,
25
- updateSectionSettings,
26
32
  validatePage,
27
33
  type EditorPage,
28
34
  type ValidationResult,
@@ -34,7 +40,6 @@ import {
34
40
  createPreviewToken,
35
41
  } from '../../lib/preview-link.js'
36
42
  import { usePublicSiteUrl } from '../../hooks/usePublicSiteUrl.js'
37
- import type { SectionSettings } from './section-types.js'
38
43
  import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
39
44
  import type { Zoom } from '../../lib/breakpoints.js'
40
45
  import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
@@ -56,7 +61,7 @@ import { EditorCanvas } from './EditorCanvas.js'
56
61
  import { ReflowContext } from './reflow-context.js'
57
62
  import { SitePreviewFrame } from './SitePreviewFrame.js'
58
63
  import { useSitePreview } from './useSitePreview.js'
59
- import { SectionInspector } from './SectionInspector.js'
64
+ import { SectionEditForm } from './SectionEditForm.js'
60
65
  import { PageSettingsModal } from './PageSettingsModal.js'
61
66
  import { ValidationSummary } from './ValidationSummary.js'
62
67
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
@@ -221,6 +226,12 @@ export function PageSectionEditor({
221
226
  /** Active rail tab. Replaces the old boolean `seoOpen` right-hand aside. */
222
227
  const [railTab, setRailTab] = useState<RailTabId>('sections')
223
228
  const [historyOpen, setHistoryOpen] = useState(false)
229
+ const [versions, setVersions] = useState<PageVersion[]>([])
230
+ const [versionsLoading, setVersionsLoading] = useState(false)
231
+ const [versionsError, setVersionsError] = useState<string | null>(null)
232
+ /** Index into `versions`; 0 is the newest, which is the current content. */
233
+ const [versionIndex, setVersionIndex] = useState(0)
234
+ const [restoring, setRestoring] = useState(false)
224
235
  const [confirmPublish, setConfirmPublish] = useState(false)
225
236
  const [scheduleOpen, setScheduleOpen] = useState(false)
226
237
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
@@ -325,6 +336,105 @@ export function PageSectionEditor({
325
336
 
326
337
  const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
327
338
 
339
+ // ─── Version history ─────────────────────────────────────────────────
340
+ // History is a MODE, not a dialog: it overlays the rail and drives the
341
+ // canvas, so the version being reviewed is editor state rather than
342
+ // something owned by a panel that can be unmounted.
343
+
344
+ const loadVersions = useCallback(async (pageId: string) => {
345
+ setVersionsLoading(true)
346
+ setVersionsError(null)
347
+ try {
348
+ setVersions(await fetchPageVersions(PAGES_COLLECTION, pageId))
349
+ } catch (err) {
350
+ setVersions([])
351
+ setVersionsError(err instanceof Error ? err.message : 'Failed to load version history')
352
+ } finally {
353
+ setVersionsLoading(false)
354
+ }
355
+ }, [])
356
+
357
+ const openHistory = useCallback(() => {
358
+ if (!page?.id) return
359
+ setHistoryOpen(true)
360
+ // Always land on "current" so opening history never silently swaps the
361
+ // canvas out from under the editor.
362
+ setVersionIndex(0)
363
+ void loadVersions(page.id)
364
+ }, [page?.id, loadVersions])
365
+
366
+ const closeHistory = useCallback(() => {
367
+ setHistoryOpen(false)
368
+ setVersionIndex(0)
369
+ }, [])
370
+
371
+ /**
372
+ * Index 0 is the current content, which the canvas is already showing — so
373
+ * selecting it is "back to current", not a review. Only an older index puts
374
+ * the editor into review mode.
375
+ */
376
+ const reviewing = historyOpen && versionIndex > 0
377
+ const reviewVersion = reviewing ? (versions[versionIndex] ?? null) : null
378
+
379
+ // What that version's save CHANGED — diffed against the one before it, which
380
+ // is the question the outlines on the canvas answer.
381
+ const reviewChanges = useMemo(
382
+ () =>
383
+ reviewVersion
384
+ ? diffVersions(reviewVersion.sections, versions[versionIndex + 1]?.sections)
385
+ : [],
386
+ [reviewVersion, versions, versionIndex],
387
+ )
388
+ const reviewChangedIds = useMemo(() => changedSectionIds(reviewChanges), [reviewChanges])
389
+
390
+ const handleRestoreVersion = useCallback(
391
+ async (version: PageVersion) => {
392
+ if (!page?.id) return
393
+ setRestoring(true)
394
+ try {
395
+ await restorePageVersion(PAGES_COLLECTION, page.id, version.id)
396
+ // Re-read rather than trusting the snapshot we already hold: the
397
+ // restore goes through the normal update path, so hooks and derived
398
+ // fields may have altered what actually landed.
399
+ const restored = await fetchPageForEditor(page.id)
400
+ acceptPage(restored)
401
+ setSelectedId(null)
402
+ setDirty(false)
403
+ setSaveState('idle')
404
+ setValidation(null)
405
+ closeHistory()
406
+ void loadVersions(page.id)
407
+ toast.success('Version restored')
408
+ } catch (err) {
409
+ toast.error(err instanceof Error ? err.message : 'Failed to restore version')
410
+ } finally {
411
+ setRestoring(false)
412
+ }
413
+ },
414
+ [page?.id, acceptPage, closeHistory, loadVersions],
415
+ )
416
+
417
+ // ─── Edit permission ─────────────────────────────────────────────────
418
+ /**
419
+ * ADR 0002: a Page-Builder-owned page (it has a `_layout`) compiles its
420
+ * sections from the builder layout on every save, so editing them here would
421
+ * be overwritten. The server independently refuses such edits with 409.
422
+ */
423
+ const readOnly = page?.builderOwned ?? false
424
+ /**
425
+ * The single gate every writing surface reads.
426
+ *
427
+ * Hoisted above the callbacks rather than derived at render time, because
428
+ * the two that were left behind were the ones that mattered: the site
429
+ * preview's in-place edit handlers close over `canEdit` (the raw role) and
430
+ * so kept accepting edits while a past version was under review — and while
431
+ * a builder-owned page was supposedly read-only. The iframe is a second
432
+ * editing surface, and a lock that only covers the rail is not a lock.
433
+ */
434
+ const effectiveCanEdit = canEdit && !readOnly && !reviewing
435
+ /** Restoring is not editing: it is allowed precisely while reviewing. */
436
+ const canRestoreVersion = canEdit && !readOnly
437
+
328
438
  // ─── Load ──────────────────────────────────────────────────────────
329
439
  useEffect(() => {
330
440
  let cancelled = false
@@ -396,13 +506,12 @@ export function PageSectionEditor({
396
506
  [selectedId, mutateSections],
397
507
  )
398
508
 
399
- const handleSettingsChange = useCallback(
400
- (patch: Partial<SectionSettings>) => {
401
- if (!selectedId) return
402
- mutateSections((s) => updateSectionSettings(s, selectedId, patch))
403
- },
404
- [selectedId, mutateSections],
405
- )
509
+ // NOTE: there is deliberately no `handleSettingsChange` here any more.
510
+ // `settings` holds background, spacing, anchor and CSS class — the template's
511
+ // business, not the editor's (docs/design-owned-sections.md). The values
512
+ // still round-trip through save untouched and the renderers still read them;
513
+ // this editor simply no longer offers to change them. The post TEMPLATE
514
+ // editor keeps the full set, because that is the surface that authors one.
406
515
 
407
516
  const handleMetaChange = useCallback(
408
517
  (patch: { name?: string; internalLabel?: string }) => {
@@ -610,24 +719,6 @@ export function PageSectionEditor({
610
719
  onNavigate('/pages')
611
720
  }, [page?.id, onNavigate])
612
721
 
613
- // After a version restore the server has already written the restored
614
- // data — refetch so the editor reflects it instead of the stale state.
615
- const handleRestored = useCallback(() => {
616
- if (!page?.id) return
617
- fetchPageForEditor(page.id)
618
- .then((p) => {
619
- acceptPage(p)
620
- setSelectedId(null)
621
- setRailTab('sections')
622
- setDirty(false)
623
- setSaveState('idle')
624
- setValidation(null)
625
- })
626
- .catch(() => {
627
- toast.error('Version restored — refresh the page to see it.')
628
- })
629
- }, [page?.id])
630
-
631
722
  const handleSeoSaved = useCallback(() => {
632
723
  reloadAudit()
633
724
  if (!page?.id) return
@@ -656,25 +747,28 @@ export function PageSectionEditor({
656
747
  setPendingFocusPath(fieldPath ?? null)
657
748
  }, [])
658
749
 
750
+ // `effectiveCanEdit`, not `canEdit`: the site preview is a second editing
751
+ // surface, so a role check alone let it write through both the version-review
752
+ // lock and the builder-owned lock the rest of the editor honours.
659
753
  const handleCanvasEdit = useCallback(
660
754
  (edit: { sectionId: string; fieldPath: string; value: string }) => {
661
- if (!canEdit) return
755
+ if (!effectiveCanEdit) return
662
756
  setSuppressSectionsEcho((n) => n + 1)
663
757
  mutateSections((s) =>
664
758
  updateSectionContentAtPath(s, edit.sectionId, edit.fieldPath, edit.value),
665
759
  )
666
760
  setSelectedId(edit.sectionId)
667
761
  },
668
- [canEdit, mutateSections],
762
+ [effectiveCanEdit, mutateSections],
669
763
  )
670
764
 
671
765
  const handleCanvasRequestMedia = useCallback(
672
766
  (request: { sectionId: string; fieldPath: string }) => {
673
- if (!canEdit) return
767
+ if (!effectiveCanEdit) return
674
768
  setSelectedId(request.sectionId)
675
769
  setCanvasMediaTarget(request)
676
770
  },
677
- [canEdit],
771
+ [effectiveCanEdit],
678
772
  )
679
773
 
680
774
  // After a canvas field click, focus the matching inspector input once painted.
@@ -805,14 +899,12 @@ export function PageSectionEditor({
805
899
  )
806
900
  }
807
901
 
808
- const selected = page.sections.find((s) => s.id === selectedId) ?? null
902
+ // `readOnly` / `effectiveCanEdit` / `canRestoreVersion` are computed above,
903
+ // with the hooks, so the callbacks defined there share this one gate.
809
904
 
810
- // ADR 0002: a Page-Builder-owned page (it has a `_layout`) compiles its
811
- // sections from the builder layout on every save, so editing them here would
812
- // be overwritten. Open this editor read-only and route the user to the
813
- // builder. The server independently refuses such edits with 409.
814
- const readOnly = page.builderOwned
815
- const effectiveCanEdit = canEdit && !readOnly
905
+ // The canvas shows the reviewed snapshot, but `page.sections` stays the
906
+ // editor's own content throughout nothing about review mode writes to it.
907
+ const canvasSections = reviewVersion ? reviewVersion.sections : page.sections
816
908
 
817
909
  // Sections whose type has no registered definition (seed/code-owned, e.g.
818
910
  // `opal-*`). They are preserved verbatim on save but can't be edited here.
@@ -857,7 +949,8 @@ export function PageSectionEditor({
857
949
  onViewOnSite={page.id ? handleViewOnSite : undefined}
858
950
  sitePreview={sitePreview.active}
859
951
  onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
860
- onHistory={page.id ? () => setHistoryOpen(true) : undefined}
952
+ historyOpen={historyOpen}
953
+ onHistory={page.id ? () => (historyOpen ? closeHistory() : openHistory()) : undefined}
861
954
  onOpenSettings={() => setSettingsOpen(true)}
862
955
  onOpenSeo={handleToggleSeo}
863
956
  seoOpen={railTab === 'seo'}
@@ -926,7 +1019,7 @@ export function PageSectionEditor({
926
1019
  )}
927
1020
 
928
1021
  <ReflowContext.Provider value={reflowContext}>
929
- <div className="relative flex flex-1 overflow-hidden">
1022
+ <div className="prenta-editor-body relative flex flex-1 overflow-hidden">
930
1023
  <EditorRail collapsedLabel={activeRailTab.label}>
931
1024
  {({ width, dragging, collapse }) => (
932
1025
  <>
@@ -951,6 +1044,21 @@ export function PageSectionEditor({
951
1044
  canEdit={effectiveCanEdit}
952
1045
  onSelect={handleSelectSection}
953
1046
  onToggleVisibility={handleToggleVisibility}
1047
+ // The editor opens inside the row it belongs to. While
1048
+ // reviewing a past version there is nothing to edit, so
1049
+ // rows stay closed rather than opening a dead form.
1050
+ renderEditor={
1051
+ reviewing
1052
+ ? undefined
1053
+ : (section) => (
1054
+ <SectionEditForm
1055
+ section={section}
1056
+ canEdit={effectiveCanEdit}
1057
+ onContentChange={handleContentChange}
1058
+ onMetaChange={handleMetaChange}
1059
+ />
1060
+ )
1061
+ }
954
1062
  />
955
1063
  ) : railTab === 'page' ? (
956
1064
  <div className="min-h-0 flex-1 overflow-y-auto">
@@ -992,6 +1100,23 @@ export function PageSectionEditor({
992
1100
  />
993
1101
  )}
994
1102
  </div>
1103
+
1104
+ {/* Overlays the rail rather than replacing its contents, so
1105
+ closing history returns to exactly the tab and scroll
1106
+ position it was opened from. */}
1107
+ {historyOpen && (
1108
+ <VersionHistoryPanel
1109
+ versions={versions}
1110
+ loading={versionsLoading}
1111
+ error={versionsError}
1112
+ selectedIndex={versionIndex}
1113
+ onSelect={setVersionIndex}
1114
+ onClose={closeHistory}
1115
+ onRestore={handleRestoreVersion}
1116
+ restoring={restoring}
1117
+ canRestore={canRestoreVersion}
1118
+ />
1119
+ )}
995
1120
  </>
996
1121
  )}
997
1122
  </EditorRail>
@@ -1012,7 +1137,7 @@ export function PageSectionEditor({
1012
1137
  />
1013
1138
  ) : (
1014
1139
  <EditorCanvas
1015
- sections={page.sections}
1140
+ sections={canvasSections}
1016
1141
  selectedId={selectedId}
1017
1142
  breakpoint={viewport}
1018
1143
  zoom={zoom}
@@ -1021,35 +1146,27 @@ export function PageSectionEditor({
1021
1146
  onSelect={handleSelectSection}
1022
1147
  structuralTypes={structuralTypes}
1023
1148
  sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
1149
+ reviewing={reviewing}
1150
+ changedSectionIds={reviewChangedIds}
1151
+ reviewBar={
1152
+ reviewVersion ? (
1153
+ <ReviewBar
1154
+ version={reviewVersion}
1155
+ changedCount={reviewChangedIds.size}
1156
+ onRestore={() => void handleRestoreVersion(reviewVersion)}
1157
+ onBackToCurrent={() => setVersionIndex(0)}
1158
+ restoring={restoring}
1159
+ canRestore={canRestoreVersion}
1160
+ />
1161
+ ) : null
1162
+ }
1024
1163
  />
1025
1164
  )}
1026
1165
  </div>
1027
-
1028
- {selected && railTab === 'sections' && (
1029
- <SectionInspector
1030
- section={selected}
1031
- canEdit={effectiveCanEdit}
1032
- onClose={() => setSelectedId(null)}
1033
- onContentChange={handleContentChange}
1034
- onSettingsChange={handleSettingsChange}
1035
- onMetaChange={handleMetaChange}
1036
- onToggleVisibility={() => handleToggleVisibility(selected.id)}
1037
- />
1038
- )}
1039
1166
  </div>
1040
1167
  </ReflowContext.Provider>
1041
1168
  </div>
1042
1169
 
1043
- {page.id && (
1044
- <VersionHistory
1045
- collectionSlug="pages"
1046
- documentId={page.id}
1047
- open={historyOpen}
1048
- onClose={() => setHistoryOpen(false)}
1049
- onRestore={handleRestored}
1050
- />
1051
- )}
1052
-
1053
1170
  <PageSettingsModal
1054
1171
  open={settingsOpen}
1055
1172
  page={page}
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { History, Loader2 } from 'lucide-react'
4
+ import { dayLabel, timeLabel, type PageVersion } from '../../lib/version-history.js'
5
+
6
+ export interface ReviewBarProps {
7
+ version: PageVersion
8
+ changedCount: number
9
+ onRestore: () => void
10
+ onBackToCurrent: () => void
11
+ restoring: boolean
12
+ canRestore: boolean
13
+ }
14
+
15
+ /**
16
+ * The strip above the canvas while a past version is on it.
17
+ *
18
+ * Occupies the same slot as the editing tip and the structure notice, and
19
+ * replaces both rather than stacking with them: everything that slot normally
20
+ * says is about editing the current page, which is precisely what you cannot
21
+ * do here. It also has to be unmissable — the canvas below looks like a
22
+ * perfectly ordinary editable page, and the only thing separating "reviewing
23
+ * history" from "editing" is this bar.
24
+ */
25
+ export function ReviewBar({
26
+ version,
27
+ changedCount,
28
+ onRestore,
29
+ onBackToCurrent,
30
+ restoring,
31
+ canRestore,
32
+ }: ReviewBarProps) {
33
+ return (
34
+ <div
35
+ // `status`, not a bare div: entering review mode is a change of what the
36
+ // whole canvas means, and a screen-reader user gets no equivalent of the
37
+ // amber colour that announces it to everyone else.
38
+ role="status"
39
+ className="border-warning/35 bg-warning/10 text-foreground mx-auto mb-3 flex w-fit max-w-full flex-wrap items-center gap-2.5 rounded-full border py-1.5 pr-1.5 pl-3.5 text-[11.5px]"
40
+ >
41
+ <History className="text-warning h-3.5 w-3.5 shrink-0" aria-hidden />
42
+ <span className="min-w-0">
43
+ Viewing{' '}
44
+ <strong className="font-semibold">
45
+ {dayLabel(version.createdAt)} {timeLabel(version.createdAt)}
46
+ </strong>{' '}
47
+ by <strong className="font-semibold">{version.author.name}</strong>
48
+ {changedCount > 0 && (
49
+ <>
50
+ {' · '}
51
+ {changedCount} section{changedCount === 1 ? '' : 's'} changed
52
+ </>
53
+ )}
54
+ </span>
55
+
56
+ {canRestore && (
57
+ <button
58
+ type="button"
59
+ onClick={onRestore}
60
+ disabled={restoring}
61
+ className="border-border bg-card text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
62
+ >
63
+ {restoring && <Loader2 className="h-3 w-3 animate-spin" aria-hidden />}
64
+ Restore
65
+ </button>
66
+ )}
67
+ <button
68
+ type="button"
69
+ onClick={onBackToCurrent}
70
+ className="border-border bg-card text-foreground hover:bg-accent shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors"
71
+ >
72
+ Back to current
73
+ </button>
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ import { ExternalLink, Lock } from 'lucide-react'
4
+ import { RichTextField } from '../../fields/RichTextField.js'
5
+ import type { PageSection } from '../../lib/page-editor-service.js'
6
+ import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
7
+ import { getSectionType } from './section-types.js'
8
+
9
+ export interface SectionEditFormProps {
10
+ section: PageSection
11
+ canEdit: boolean
12
+ onContentChange: (patch: Record<string, unknown>) => void
13
+ onMetaChange: (patch: { name?: string; internalLabel?: string }) => void
14
+ /** Post body field — shown when an Article Body section is bound to the post field. */
15
+ postBody?: string
16
+ onPostBodyChange?: (html: string) => void
17
+ }
18
+
19
+ /**
20
+ * The section editor, rendered inline as the body of its row.
21
+ *
22
+ * ── Why inline, and not a docked panel ────────────────────────────────────
23
+ *
24
+ * The design puts the form inside the row it belongs to: click a section, it
25
+ * opens underneath. It never had a right-hand panel. The panel that existed
26
+ * here was left over from the pre-handoff editor, and the rail work that
27
+ * introduced the tab strip explicitly retired the OTHER right-hand aside — the
28
+ * SEO one — on the grounds that "the editor has one settings surface rather
29
+ * than two competing ones". This is that same second surface, finally closed.
30
+ *
31
+ * ── Why there is no Appearance group ──────────────────────────────────────
32
+ *
33
+ * This CMS edits words, images and embed code. Background, colour and spacing
34
+ * are the template's business, and `docs/design-owned-sections.md` already
35
+ * says so in as many words: the editor changes "copy, CTAs, and imagery —
36
+ * never layout, order, or styling". The controls contradicted that rule while
37
+ * sitting one panel away from the sentence stating it.
38
+ *
39
+ * The VALUES are untouched. `settings` round-trips through save exactly as
40
+ * before, the renderers keep reading it, and every existing page renders
41
+ * identically — the editor simply stops offering to change it. Removing the
42
+ * controls and dropping the data are very different changes, and this is only
43
+ * the first. The template editor keeps the full set: it is the surface that
44
+ * authors a template, and styling genuinely belongs there.
45
+ */
46
+ export function SectionEditForm({
47
+ section,
48
+ canEdit,
49
+ onContentChange,
50
+ onMetaChange,
51
+ postBody,
52
+ onPostBodyChange,
53
+ }: SectionEditFormProps) {
54
+ const def = getSectionType(section.sectionType)
55
+
56
+ // Unmanaged types have no registered field schema, so there is nothing to
57
+ // render a form from. Say why rather than showing an empty box.
58
+ if (section.unmanaged) {
59
+ return (
60
+ <div className="border-border bg-muted/40 border-t px-3 py-3">
61
+ <p className="text-muted-foreground flex items-start gap-2 text-xs leading-relaxed">
62
+ <Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
63
+ <span>
64
+ <span className="text-foreground font-medium">Managed outside the editor</span> — this
65
+ section&rsquo;s type (<code className="font-mono">{section.sectionType}</code>) is not
66
+ registered, so it is preserved on save but edited in code.
67
+ </span>
68
+ </p>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ const bodySource = String(section.content.source ?? 'field')
74
+ const editPostBodyField =
75
+ section.sectionType === 'article-body' &&
76
+ bodySource === 'field' &&
77
+ onPostBodyChange !== undefined
78
+ const contentFields = def?.fields.filter((f) => !(editPostBodyField && f.key === 'body')) ?? []
79
+
80
+ return (
81
+ <div className="border-border bg-muted/40 border-t px-3 py-3.5">
82
+ <fieldset disabled={!canEdit} className="space-y-3">
83
+ {editPostBodyField && (
84
+ <RichTextField
85
+ label="Body"
86
+ value={postBody ?? ''}
87
+ onChange={onPostBodyChange}
88
+ className="[&_.ProseMirror]:min-h-[240px]"
89
+ helpText="Main article copy — rendered in the canvas preview and on the live site."
90
+ />
91
+ )}
92
+
93
+ {contentFields.map((field) => (
94
+ <ContentField
95
+ key={field.key}
96
+ id={`sec-${section.id}-${field.key}`}
97
+ field={field}
98
+ value={section.content[field.key]}
99
+ onChange={(v) => onContentChange({ [field.key]: v })}
100
+ onPatch={(patch) => onContentChange(patch)}
101
+ />
102
+ ))}
103
+
104
+ {contentFields.length === 0 && !editPostBodyField && (
105
+ <p className="text-muted-foreground text-xs">
106
+ This section has no editable content — it is rendered entirely from the template.
107
+ </p>
108
+ )}
109
+
110
+ <details className="group">
111
+ <summary className="text-muted-foreground hover:text-foreground cursor-pointer list-none text-[11px] font-medium tracking-wider uppercase">
112
+ Section settings
113
+ </summary>
114
+ <div className="mt-2.5 space-y-3">
115
+ <div>
116
+ <label className={FIELD_LABEL} htmlFor={`sec-${section.id}-name`}>
117
+ Section name
118
+ </label>
119
+ <input
120
+ id={`sec-${section.id}-name`}
121
+ className={FIELD_INPUT}
122
+ value={section.name}
123
+ onChange={(e) => onMetaChange({ name: e.target.value })}
124
+ />
125
+ </div>
126
+ <div>
127
+ <label className={FIELD_LABEL} htmlFor={`sec-${section.id}-internal`}>
128
+ Internal label
129
+ </label>
130
+ <input
131
+ id={`sec-${section.id}-internal`}
132
+ className={FIELD_INPUT}
133
+ placeholder="Team-only note (optional)"
134
+ value={section.internalLabel ?? ''}
135
+ onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
136
+ />
137
+ </div>
138
+ </div>
139
+ </details>
140
+ </fieldset>
141
+
142
+ {/* The design's locked note, per section rather than once per rail: this
143
+ is where an editor looks for the control that is missing. */}
144
+ <p className="text-muted-foreground mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed">
145
+ <Lock className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
146
+ <span>
147
+ Background, spacing and layout come from the template.{' '}
148
+ <a
149
+ href="https://github.com/actuate-media/prenta/blob/main/docs/design-owned-sections.md"
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ className="text-brand inline-flex items-center gap-0.5 hover:underline"
153
+ >
154
+ Why
155
+ <ExternalLink className="h-2.5 w-2.5" aria-hidden />
156
+ </a>
157
+ </span>
158
+ </p>
159
+ </div>
160
+ )
161
+ }