@prenta/admin 1.1.0 → 1.3.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 (121) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/lib/canvas-edit-chrome.test.js +1 -22
  3. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -1
  4. package/dist/__tests__/lib/page-audit-input.test.js +81 -0
  5. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  6. package/dist/__tests__/lib/preview-bridge.test.js +19 -79
  7. package/dist/__tests__/lib/preview-bridge.test.js.map +1 -1
  8. package/dist/__tests__/lib/version-history.test.js +18 -0
  9. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  10. package/dist/__tests__/lib/view-site-chrome.test.js +11 -1
  11. package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -1
  12. package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
  13. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  14. package/dist/__tests__/views/canvas-review-mode.render.test.js +11 -1
  15. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -1
  16. package/dist/__tests__/views/editor-top-bar.render.test.js +13 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  18. package/dist/__tests__/views/page-section-editor.test.js +23 -9
  19. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  20. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  21. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  22. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  25. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  26. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  27. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  28. package/dist/components/seo/SeoEditorPane.js +5 -2
  29. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  30. package/dist/hooks/usePageAudit.d.ts +4 -1
  31. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  32. package/dist/hooks/usePageAudit.js +3 -3
  33. package/dist/hooks/usePageAudit.js.map +1 -1
  34. package/dist/lib/canvas-edit-chrome.d.ts +0 -17
  35. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -1
  36. package/dist/lib/canvas-edit-chrome.js +0 -62
  37. package/dist/lib/canvas-edit-chrome.js.map +1 -1
  38. package/dist/lib/page-audit-input.d.ts +26 -1
  39. package/dist/lib/page-audit-input.d.ts.map +1 -1
  40. package/dist/lib/page-audit-input.js +48 -2
  41. package/dist/lib/page-audit-input.js.map +1 -1
  42. package/dist/lib/preview-bridge.d.ts +7 -74
  43. package/dist/lib/preview-bridge.d.ts.map +1 -1
  44. package/dist/lib/preview-bridge.js +12 -73
  45. package/dist/lib/preview-bridge.js.map +1 -1
  46. package/dist/lib/version-history.d.ts +5 -0
  47. package/dist/lib/version-history.d.ts.map +1 -1
  48. package/dist/lib/version-history.js +18 -0
  49. package/dist/lib/version-history.js.map +1 -1
  50. package/dist/lib/view-site-chrome.d.ts +3 -0
  51. package/dist/lib/view-site-chrome.d.ts.map +1 -1
  52. package/dist/lib/view-site-chrome.js +11 -2
  53. package/dist/lib/view-site-chrome.js.map +1 -1
  54. package/dist/views/page-editor/EditorCanvas.d.ts +1 -1
  55. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  56. package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
  57. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  58. package/dist/views/page-editor/EditorTopBar.js +3 -2
  59. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.js +8 -47
  62. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  63. package/dist/views/page-editor/sections/SectionRenderer.d.ts +1 -1
  64. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  65. package/dist/views/page-editor/sections/SectionRenderer.js +2 -1
  66. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  67. package/dist/views/post-editor/PostEditorCanvas.d.ts +15 -2
  68. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  69. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  70. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  71. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  72. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  73. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  74. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  75. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  76. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  77. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  78. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  79. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  80. package/dist/views/post-editor/PostSectionEditor.js +128 -74
  81. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  82. package/dist/views/post-editor/PostTab.d.ts +17 -0
  83. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  84. package/dist/views/post-editor/PostTab.js +23 -0
  85. package/dist/views/post-editor/PostTab.js.map +1 -0
  86. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  87. package/dist/views/settings/SeoSettingsTab.js +2 -1
  88. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  89. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  90. package/dist/views/settings/SiteInformationCard.js +3 -1
  91. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  92. package/package.json +4 -4
  93. package/src/__tests__/lib/canvas-edit-chrome.test.ts +0 -27
  94. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  95. package/src/__tests__/lib/preview-bridge.test.ts +21 -110
  96. package/src/__tests__/lib/version-history.test.ts +20 -0
  97. package/src/__tests__/lib/view-site-chrome.test.ts +16 -1
  98. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  99. package/src/__tests__/views/canvas-review-mode.render.test.tsx +14 -1
  100. package/src/__tests__/views/editor-top-bar.render.test.tsx +25 -0
  101. package/src/__tests__/views/page-section-editor.test.tsx +23 -13
  102. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  103. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  104. package/src/components/seo/SeoEditorPane.tsx +16 -1
  105. package/src/hooks/usePageAudit.ts +9 -2
  106. package/src/lib/canvas-edit-chrome.ts +0 -64
  107. package/src/lib/page-audit-input.ts +67 -1
  108. package/src/lib/preview-bridge.ts +11 -157
  109. package/src/lib/version-history.ts +23 -0
  110. package/src/lib/view-site-chrome.ts +15 -3
  111. package/src/views/page-editor/EditorCanvas.tsx +1 -1
  112. package/src/views/page-editor/EditorTopBar.tsx +6 -2
  113. package/src/views/page-editor/PageSectionEditor.tsx +6 -63
  114. package/src/views/page-editor/sections/SectionRenderer.tsx +5 -2
  115. package/src/views/post-editor/PostEditorCanvas.tsx +28 -6
  116. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  117. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  118. package/src/views/post-editor/PostSectionEditor.tsx +209 -116
  119. package/src/views/post-editor/PostTab.tsx +46 -0
  120. package/src/views/settings/SeoSettingsTab.tsx +21 -0
  121. package/src/views/settings/SiteInformationCard.tsx +7 -0
@@ -10,7 +10,16 @@ import { EditorRail, RailHeader } from '../../components/EditorRail.js'
10
10
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
11
11
  import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
12
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
- import { VersionHistory } from '../../components/VersionHistory.js'
13
+ import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
14
+ import { ReviewBar } from '../page-editor/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'
21
+ import { scoreBand, type PageAuditResult } from '@prenta/core/seo/page-audit'
22
+ import { usePageAudit } from '../../hooks/usePageAudit.js'
14
23
  import {
15
24
  emptyPostFromTemplate,
16
25
  fetchPostForEditor,
@@ -20,14 +29,12 @@ import {
20
29
  savePostDraft,
21
30
  toggleSectionVisibility,
22
31
  updateSectionContent,
23
- updateSectionContentAtPath,
24
32
  updateSectionMeta,
25
33
  validatePost,
26
34
  type EditorPost,
27
35
  type PostHeaderConfig,
28
36
  type ValidationResult,
29
37
  } from '../../lib/post-editor-service.js'
30
- import { MediaPickerModal } from '../../components/MediaPickerModal.js'
31
38
  import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
32
39
  import { defaultPostHeader } from '@prenta/core/page-sections'
33
40
  import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
@@ -43,13 +50,25 @@ import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
43
50
  import { SectionsList } from '../page-editor/SectionsPanel.js'
44
51
  import { SectionEditForm } from '../page-editor/SectionEditForm.js'
45
52
  import { ValidationSummary } from '../page-editor/ValidationSummary.js'
46
- import { buildEditorContentHtml, computeLiveSeoScore } from '../../lib/editor-seo-content.js'
53
+ import { buildEditorContentHtml } from '../../lib/editor-seo-content.js'
47
54
  import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
48
55
  import { ReflowContext } from '../page-editor/reflow-context.js'
49
56
  import type { Zoom } from '../../lib/breakpoints.js'
50
- import { companionDimensionPaths, inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
57
+ import { inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
51
58
  import { PostEditorCanvas } from './PostEditorCanvas.js'
52
59
  import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
60
+ import { PostTab } from './PostTab.js'
61
+
62
+ /**
63
+ * The lower of the two audit scores — the tab dot should reflect the half that
64
+ * needs work, not an average that hides it. Same three-valued contract as the
65
+ * page editor: undefined = not reported yet, null = gated off, number = score.
66
+ */
67
+ function weakestAuditScore(audit: PageAuditResult | null): number | null | undefined {
68
+ if (!audit) return undefined
69
+ const scores = [audit.seo.score, audit.geo.score].filter((s): s is number => s !== null)
70
+ return scores.length ? Math.min(...scores) : null
71
+ }
53
72
 
54
73
  /** The slice of the resolved CMS config this editor reads. */
55
74
  interface EditorConfig {
@@ -108,13 +127,6 @@ export function PostSectionEditor({
108
127
  const [selectedId, setSelectedId] = useState<string | null>(null)
109
128
  /** Canvas field click — focused after the inspector mounts for `selectedId`. */
110
129
  const [pendingFocusPath, setPendingFocusPath] = useState<string | null>(null)
111
- /** Bump to skip the next `prenta-preview:sections` echo after a canvas text edit. */
112
- const [suppressSectionsEcho, setSuppressSectionsEcho] = useState(0)
113
- /** Canvas image Replace — opens MediaPickerModal for this field path. */
114
- const [canvasMediaTarget, setCanvasMediaTarget] = useState<{
115
- sectionId: string
116
- fieldPath: string
117
- } | null>(null)
118
130
  const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
119
131
  const [zoom, setZoom] = useState<Zoom>('fit')
120
132
  /** Scale the canvas measured — reported upward so the zoom chip shows a real %. */
@@ -136,6 +148,18 @@ export function PostSectionEditor({
136
148
  const [railTab, setRailTab] = useState<RailTabId>('sections')
137
149
  const seoOpen = railTab === 'seo'
138
150
  const [historyOpen, setHistoryOpen] = useState(false)
151
+ const [versions, setVersions] = useState<PageVersion[]>([])
152
+ const [versionsLoading, setVersionsLoading] = useState(false)
153
+ const [versionsError, setVersionsError] = useState<string | null>(null)
154
+ /** Index into `versions`; 0 is the newest, which is the current content. */
155
+ const [versionIndex, setVersionIndex] = useState(0)
156
+ const [restoring, setRestoring] = useState(false)
157
+ /**
158
+ * Live audit reported by the SEO pane, built from unsaved form state. Null
159
+ * until that pane has mounted — `audit` below falls back to the saved-record
160
+ * audit so the header chip and tab dot always have a number.
161
+ */
162
+ const [liveAudit, setLiveAudit] = useState<PageAuditResult | null>(null)
139
163
  const [confirmPublish, setConfirmPublish] = useState(false)
140
164
  const [scheduleOpen, setScheduleOpen] = useState(false)
141
165
  const [shareOpen, setShareOpen] = useState(false)
@@ -158,7 +182,7 @@ export function PostSectionEditor({
158
182
 
159
183
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
160
184
  const customRenderers = useAdminSectionRenderers()
161
- const { siteUrl: publicSiteUrl } = usePublicSiteUrl(config?.seo?.siteUrl)
185
+ const { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
162
186
 
163
187
  const typeLabel = useMemo(() => {
164
188
  const col = config?.collections?.[postType]
@@ -188,6 +212,20 @@ export function PostSectionEditor({
188
212
  )
189
213
  const publishReady = Boolean(post) && publishCheck.errors.length === 0
190
214
 
215
+ const { audit: savedAudit } = usePageAudit({
216
+ entityType: 'post',
217
+ entityId: post?.id ?? null,
218
+ sections: post?.sections ?? [],
219
+ slug: post?.slug ?? '',
220
+ fallback: { title: post?.seoTitle, description: post?.seoDescription },
221
+ post: {
222
+ title: post?.title,
223
+ bodyHtml: post?.body,
224
+ featuredImage: post?.featuredImage,
225
+ },
226
+ })
227
+ const audit = liveAudit ?? savedAudit
228
+
191
229
  const railTabs = useMemo<RailTab[]>(
192
230
  () => [
193
231
  {
@@ -196,13 +234,19 @@ export function PostSectionEditor({
196
234
  title: 'Post content',
197
235
  count: post?.sections.length ?? 0,
198
236
  },
199
- // No score dot yet. The page editor's comes from the page audit, which
200
- // is page-scoped; wiring a number here from `computeLiveSeoScore` would
201
- // put a different model's score on a tab, which is the disagreement the
202
- // page editor was rewired to remove.
203
- { id: 'seo', label: 'SEO & GEO', title: 'Search & AI readiness' },
237
+ {
238
+ id: 'seo',
239
+ label: 'SEO & GEO',
240
+ title: 'Search & AI readiness',
241
+ score: weakestAuditScore(audit),
242
+ },
243
+ {
244
+ id: 'page',
245
+ label: 'Post',
246
+ title: 'Post settings',
247
+ },
204
248
  ],
205
- [post?.sections.length],
249
+ [post?.sections.length, audit],
206
250
  )
207
251
  const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
208
252
 
@@ -227,17 +271,6 @@ export function PostSectionEditor({
227
271
  [canvasEl, selectedId, baseline, viewport],
228
272
  )
229
273
 
230
- const liveSeoScore = useMemo(() => {
231
- if (!post) return 0
232
- return computeLiveSeoScore({
233
- title: post.title,
234
- slug: post.slug,
235
- seoTitle: post.seoTitle,
236
- seoDescription: post.seoDescription,
237
- sections: post.sections,
238
- bodyHtml: post.body,
239
- })
240
- }, [post])
241
274
  const publishDisabledReason = publishCheck.errors[0]
242
275
 
243
276
  // ─── Load ──────────────────────────────────────────────────────────
@@ -446,23 +479,77 @@ export function PostSectionEditor({
446
479
  }
447
480
  }, [post, documentId, postType, onNavigate])
448
481
 
449
- // After a version restore the server has already written the restored
450
- // data refetch so the editor reflects it instead of the stale state.
451
- const handleRestored = useCallback(() => {
482
+ // ─── Version history ─────────────────────────────────────────────────
483
+ // History is a MODE, not a dialog: it overlays the rail and drives the
484
+ // canvas, so the version being reviewed is editor state rather than
485
+ // something owned by a panel that can be unmounted.
486
+
487
+ const loadVersions = useCallback(
488
+ async (postId: string) => {
489
+ setVersionsLoading(true)
490
+ setVersionsError(null)
491
+ try {
492
+ setVersions(await fetchPageVersions(postType, postId))
493
+ } catch (err) {
494
+ setVersions([])
495
+ setVersionsError(err instanceof Error ? err.message : 'Failed to load version history')
496
+ } finally {
497
+ setVersionsLoading(false)
498
+ }
499
+ },
500
+ [postType],
501
+ )
502
+
503
+ const openHistory = useCallback(() => {
452
504
  if (!post?.id) return
453
- fetchPostForEditor(postType, post.id)
454
- .then((p) => {
455
- acceptPost(p)
505
+ setHistoryOpen(true)
506
+ setVersionIndex(0)
507
+ void loadVersions(post.id)
508
+ }, [post?.id, loadVersions])
509
+
510
+ const closeHistory = useCallback(() => {
511
+ setHistoryOpen(false)
512
+ setVersionIndex(0)
513
+ }, [])
514
+
515
+ const reviewing = historyOpen && versionIndex > 0
516
+ const reviewVersion = reviewing ? (versions[versionIndex] ?? null) : null
517
+
518
+ const reviewChanges = useMemo(
519
+ () =>
520
+ reviewVersion
521
+ ? diffVersions(reviewVersion.sections, versions[versionIndex + 1]?.sections)
522
+ : [],
523
+ [reviewVersion, versions, versionIndex],
524
+ )
525
+ const reviewChangedIds = useMemo(() => changedSectionIds(reviewChanges), [reviewChanges])
526
+
527
+ const handleRestoreVersion = useCallback(
528
+ async (version: PageVersion) => {
529
+ if (!post?.id) return
530
+ setRestoring(true)
531
+ try {
532
+ await restorePageVersion(postType, post.id, version.id)
533
+ const restored = await fetchPostForEditor(postType, post.id)
534
+ acceptPost(restored)
456
535
  setSelectedId(null)
457
- setRailTab('sections')
458
536
  setDirty(false)
459
537
  setSaveState('idle')
460
538
  setValidation(null)
461
- })
462
- .catch(() => {
463
- toast.error('Version restored — refresh the page to see it.')
464
- })
465
- }, [postType, post?.id])
539
+ closeHistory()
540
+ void loadVersions(post.id)
541
+ toast.success('Version restored')
542
+ } catch (err) {
543
+ toast.error(err instanceof Error ? err.message : 'Failed to restore version')
544
+ } finally {
545
+ setRestoring(false)
546
+ }
547
+ },
548
+ [post?.id, postType, acceptPost, closeHistory, loadVersions],
549
+ )
550
+
551
+ const effectiveCanEdit = canEdit && !reviewing
552
+ const canRestoreVersion = canEdit
466
553
 
467
554
  const handleSeoSaved = useCallback(() => {
468
555
  if (!post?.id) return
@@ -485,32 +572,11 @@ export function PostSectionEditor({
485
572
  setRailTab('sections')
486
573
  setPendingFocusPath(fieldPath ?? null)
487
574
  // Toggles, because selection is now disclosure — see the note on the page
488
- // editor's copy. A `fieldPath` means the site preview is pointing at a
489
- // specific field, which must open rather than toggle.
575
+ // editor's copy. A `fieldPath` means a canvas field click, which must
576
+ // open rather than toggle.
490
577
  setSelectedId((current) => (fieldPath ? id : current === id ? null : id))
491
578
  }, [])
492
579
 
493
- const handleCanvasEdit = useCallback(
494
- (edit: { sectionId: string; fieldPath: string; value: string }) => {
495
- if (!canEdit) return
496
- setSuppressSectionsEcho((n) => n + 1)
497
- mutateSections((s) =>
498
- updateSectionContentAtPath(s, edit.sectionId, edit.fieldPath, edit.value),
499
- )
500
- setSelectedId(edit.sectionId)
501
- },
502
- [canEdit, mutateSections],
503
- )
504
-
505
- const handleCanvasRequestMedia = useCallback(
506
- (request: { sectionId: string; fieldPath: string }) => {
507
- if (!canEdit) return
508
- setSelectedId(request.sectionId)
509
- setCanvasMediaTarget(request)
510
- },
511
- [canEdit],
512
- )
513
-
514
580
  // After a canvas field click, focus the matching inspector input once painted.
515
581
  useEffect(() => {
516
582
  if (!pendingFocusPath || !selectedId) return
@@ -620,8 +686,8 @@ export function PostSectionEditor({
620
686
  dirty={dirty}
621
687
  saveState={saveState}
622
688
  publishing={publishing}
623
- canEdit={canEdit}
624
- canPublish={canPublish && canEdit}
689
+ canEdit={effectiveCanEdit}
690
+ canPublish={canPublish && effectiveCanEdit}
625
691
  publishReady={publishReady}
626
692
  publishDisabledReason={publishDisabledReason}
627
693
  viewport={viewport}
@@ -634,11 +700,16 @@ export function PostSectionEditor({
634
700
  onViewOnSite={post.id && post.slug ? handleViewOnSite : undefined}
635
701
  onShare={post.id ? handleShare : undefined}
636
702
  hasPublicSiteUrl={Boolean(publicSiteUrl)}
637
- onHistory={post.id ? () => setHistoryOpen(true) : undefined}
703
+ matchesAdminOrigin={matchesAdminOrigin}
704
+ historyOpen={historyOpen}
705
+ onHistory={post.id ? () => (historyOpen ? closeHistory() : openHistory()) : undefined}
638
706
  onOpenSettings={() => setFieldsOpen(true)}
639
707
  onOpenSeo={handleToggleSeo}
640
708
  seoOpen={seoOpen}
641
- seoScore={liveSeoScore}
709
+ seoScore={audit?.seo.score ?? undefined}
710
+ seoScoreBand={
711
+ typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
712
+ }
642
713
  onSaveDraft={handleSaveDraft}
643
714
  onPublish={requestPublish}
644
715
  onSchedule={post.id ? requestSchedule : undefined}
@@ -696,27 +767,44 @@ export function PostSectionEditor({
696
767
  <SectionsList
697
768
  sections={post.sections}
698
769
  selectedId={selectedId}
699
- canEdit={canEdit}
770
+ canEdit={effectiveCanEdit}
700
771
  onSelect={handleSelectSection}
701
772
  onToggleVisibility={handleToggleVisibility}
702
- renderEditor={(section) => (
703
- <SectionEditForm
704
- section={section}
705
- canEdit={canEdit}
706
- onContentChange={handleContentChange}
707
- onMetaChange={handleMetaChange}
708
- postBody={post.body}
709
- onPostBodyChange={handlePostBodyChange}
710
- />
711
- )}
773
+ renderEditor={
774
+ reviewing
775
+ ? undefined
776
+ : (section) => (
777
+ <SectionEditForm
778
+ section={section}
779
+ canEdit={effectiveCanEdit}
780
+ onContentChange={handleContentChange}
781
+ onMetaChange={handleMetaChange}
782
+ postBody={post.body}
783
+ onPostBodyChange={handlePostBodyChange}
784
+ />
785
+ )
786
+ }
712
787
  />
788
+ ) : railTab === 'page' ? (
789
+ <div className="min-h-0 flex-1 overflow-y-auto">
790
+ <PostTab
791
+ post={post}
792
+ canEdit={effectiveCanEdit}
793
+ fields={config?.collections?.[postType]?.fields}
794
+ onChange={handleFields}
795
+ />
796
+ </div>
713
797
  ) : (
714
798
  <SeoEditorPane
715
799
  entityType="post"
716
800
  entityId={post.id}
717
801
  slug={post.slug}
718
802
  auditSections={post.sections}
803
+ onAuditChange={setLiveAudit}
719
804
  contentHtml={seoContentHtml}
805
+ title={post.title}
806
+ bodyHtml={post.body}
807
+ featuredImage={post.featuredImage}
720
808
  draftMeta={{
721
809
  seoTitle: post.seoTitle,
722
810
  seoDescription: post.seoDescription,
@@ -727,15 +815,39 @@ export function PostSectionEditor({
727
815
  />
728
816
  )}
729
817
  </div>
818
+
819
+ {historyOpen && (
820
+ <VersionHistoryPanel
821
+ versions={versions}
822
+ loading={versionsLoading}
823
+ error={versionsError}
824
+ selectedIndex={versionIndex}
825
+ onSelect={setVersionIndex}
826
+ onClose={closeHistory}
827
+ onRestore={handleRestoreVersion}
828
+ restoring={restoring}
829
+ canRestore={canRestoreVersion}
830
+ />
831
+ )}
730
832
  </>
731
833
  )}
732
834
  </EditorRail>
733
835
 
734
836
  <div className="flex-1 overflow-hidden">
735
837
  <PostEditorCanvas
736
- sections={post.sections}
838
+ sections={reviewVersion ? reviewVersion.sections : post.sections}
737
839
  header={header}
738
- context={context}
840
+ context={
841
+ reviewVersion
842
+ ? {
843
+ ...context,
844
+ title: reviewVersion.title ?? context.title,
845
+ excerpt: reviewVersion.excerpt ?? context.excerpt,
846
+ body: reviewVersion.body ?? context.body,
847
+ featuredImageUrl: reviewVersion.featuredImage ?? context.featuredImageUrl,
848
+ }
849
+ : context
850
+ }
739
851
  selectedId={selectedId}
740
852
  viewport={viewport}
741
853
  zoom={zoom}
@@ -743,26 +855,30 @@ export function PostSectionEditor({
743
855
  onCanvasElement={setCanvasEl}
744
856
  onSelect={handleSelectSection}
745
857
  structuralTypes={structuralTypes}
858
+ reviewing={reviewing}
859
+ changedSectionIds={reviewChangedIds}
860
+ reviewBar={
861
+ reviewVersion ? (
862
+ <ReviewBar
863
+ version={reviewVersion}
864
+ changedCount={reviewChangedIds.size}
865
+ onRestore={() => void handleRestoreVersion(reviewVersion)}
866
+ onBackToCurrent={() => setVersionIndex(0)}
867
+ restoring={restoring}
868
+ canRestore={canRestoreVersion}
869
+ />
870
+ ) : null
871
+ }
746
872
  />
747
873
  </div>
748
874
  </div>
749
875
  </ReflowContext.Provider>
750
876
  </div>
751
877
 
752
- {post.id && (
753
- <VersionHistory
754
- collectionSlug={postType}
755
- documentId={post.id}
756
- open={historyOpen}
757
- onClose={() => setHistoryOpen(false)}
758
- onRestore={handleRestored}
759
- />
760
- )}
761
-
762
878
  <PostFieldsModal
763
879
  open={fieldsOpen}
764
880
  post={post}
765
- canEdit={canEdit}
881
+ canEdit={effectiveCanEdit}
766
882
  fields={config?.collections?.[postType]?.fields}
767
883
  onClose={() => setFieldsOpen(false)}
768
884
  onSave={handleFields}
@@ -817,29 +933,6 @@ export function PostSectionEditor({
817
933
  cancelLabel="Stay"
818
934
  destructive
819
935
  />
820
-
821
- <MediaPickerModal
822
- open={canvasMediaTarget !== null}
823
- onClose={() => setCanvasMediaTarget(null)}
824
- accept="image/*"
825
- onSelectItem={(item) => {
826
- if (!canvasMediaTarget) return
827
- const { sectionId, fieldPath } = canvasMediaTarget
828
- const { widthPath, heightPath } = companionDimensionPaths(fieldPath)
829
- mutateSections((s) => {
830
- let next = updateSectionContentAtPath(s, sectionId, fieldPath, item.url)
831
- if (typeof item.width === 'number') {
832
- next = updateSectionContentAtPath(next, sectionId, widthPath, item.width)
833
- }
834
- if (typeof item.height === 'number') {
835
- next = updateSectionContentAtPath(next, sectionId, heightPath, item.height)
836
- }
837
- return next
838
- })
839
- setCanvasMediaTarget(null)
840
- }}
841
- onSelect={() => setCanvasMediaTarget(null)}
842
- />
843
936
  </ErrorBoundary>
844
937
  )
845
938
  }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { EditorPost } from '../../lib/post-editor-service.js'
5
+ import { PostFieldsForm, type PostFieldConfig } from './PostFieldsForm.js'
6
+
7
+ export interface PostTabProps {
8
+ post: EditorPost
9
+ canEdit: boolean
10
+ fields?: Record<string, PostFieldConfig | undefined>
11
+ onChange: (patch: Partial<EditorPost>) => void
12
+ }
13
+
14
+ /**
15
+ * Rail tab 3 — post identity fields only.
16
+ *
17
+ * Title, slug, excerpt, featured image, category, and tags. Layout, header
18
+ * chrome, and template controls stay design-owned on the type template.
19
+ * Address / 301 / robots / author / danger-zone belong to pages, not posts.
20
+ */
21
+ export function PostTab({ post, canEdit, fields, onChange }: PostTabProps) {
22
+ const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
23
+
24
+ return (
25
+ <div className="p-3">
26
+ <PostFieldsForm
27
+ idPrefix="post"
28
+ values={{
29
+ title: post.title,
30
+ slug: post.slug,
31
+ excerpt: post.excerpt,
32
+ featuredImage: post.featuredImage,
33
+ category: post.category,
34
+ tags: post.tags,
35
+ }}
36
+ canEdit={canEdit}
37
+ fields={fields}
38
+ postType={post.postType}
39
+ bodyHtml={post.body}
40
+ slugTouched={slugTouched}
41
+ onSlugTouched={() => setSlugTouched(true)}
42
+ onChange={(patch) => onChange(patch)}
43
+ />
44
+ </div>
45
+ )
46
+ }
@@ -13,6 +13,7 @@ import {
13
13
  Upload,
14
14
  X,
15
15
  } from 'lucide-react'
16
+ import { siteUrlMatchesAdminOrigin } from '../../lib/preview-link.js'
16
17
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
17
18
  import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
18
19
  import {
@@ -125,6 +126,26 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
125
126
 
126
127
  <SettingsValidationSummary issues={seo.issues} />
127
128
 
129
+ {!seo.siteUrl ? (
130
+ <div
131
+ role="status"
132
+ className="border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-lg border p-4 text-sm"
133
+ >
134
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
135
+ Set the public site URL in Settings → General. View site and Share stay disabled until it
136
+ is set.
137
+ </div>
138
+ ) : siteUrlMatchesAdminOrigin(seo.siteUrl) ? (
139
+ <div
140
+ role="status"
141
+ className="border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-lg border p-4 text-sm"
142
+ >
143
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
144
+ This origin is the admin, not the public site. Set the public site URL to the live apex —
145
+ View site and Share stay disabled while it matches this CMS host.
146
+ </div>
147
+ ) : null}
148
+
128
149
  <MetaDefaultsCard seo={seo} canEdit={canEdit} />
129
150
  <OrganizationCard seo={seo} canEdit={canEdit} />
130
151
  <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useId } from 'react'
4
4
  import { CheckCircle2 } from 'lucide-react'
5
+ import { siteUrlMatchesAdminOrigin } from '../../lib/preview-link.js'
5
6
  import { SettingsCard, SettingsSourceBadge } from './components.js'
6
7
  import type {
7
8
  GeneralSettingsForm,
@@ -37,6 +38,7 @@ export function SiteInformationCard({
37
38
  const urlIssue = issues.find((i) => i.field === 'siteUrl')
38
39
  const urlEditable = sources.siteUrl?.editable !== false && canEdit
39
40
  const urlValid = !urlIssue && form.siteUrl.trim().length > 0
41
+ const urlIsAdminOrigin = siteUrlMatchesAdminOrigin(form.siteUrl)
40
42
 
41
43
  return (
42
44
  <SettingsCard title="Site Information" description="Your site's name, tagline, and public URL.">
@@ -99,6 +101,11 @@ export function SiteInformationCard({
99
101
  >
100
102
  {urlIssue.message}
101
103
  </p>
104
+ ) : urlIsAdminOrigin ? (
105
+ <p role="status" className="text-warning mt-1 text-sm">
106
+ This origin is the admin, not the public site. Set the public apex (Astro or the live
107
+ Next site), not this CMS host — View site and Share will stay disabled until you do.
108
+ </p>
102
109
  ) : urlValid ? (
103
110
  <p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
104
111
  <CheckCircle2 size={13} className="text-success" aria-hidden="true" />