@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
@@ -33,6 +33,20 @@ export interface AuditMetaSource {
33
33
  hasOgImage: boolean
34
34
  }
35
35
 
36
+ /**
37
+ * Post identity the public template renders outside the section list.
38
+ *
39
+ * The audit engine only reads `sections`. Posts put the H1, opening prose, and
40
+ * featured image on the document itself, so both audit callers (saved record
41
+ * and live SEO pane) fold these into synthetic sections here — one mapping,
42
+ * so the header chip and the rings cannot disagree.
43
+ */
44
+ export interface PostAuditExtras {
45
+ title?: string
46
+ bodyHtml?: string
47
+ featuredImage?: string
48
+ }
49
+
36
50
  export interface BuildAuditInputArgs {
37
51
  sections: PageSection[]
38
52
  meta: AuditMetaSource
@@ -41,6 +55,57 @@ export interface BuildAuditInputArgs {
41
55
  record: Pick<SeoContentRecord, 'title' | 'siteName' | 'titleTemplate' | 'url'> | null
42
56
  /** Fallback title when there is no saved record yet. */
43
57
  fallbackTitle?: string
58
+ /** Post-only slots prepended so the same engine sees title / body / image. */
59
+ post?: PostAuditExtras
60
+ }
61
+
62
+ const POST_TITLE_SECTION_ID = '__post-title'
63
+ const POST_IMAGE_SECTION_ID = '__post-featured-image'
64
+
65
+ function stripHtml(html: string): string {
66
+ return html
67
+ .replace(/<[^>]*>/g, ' ')
68
+ .replace(/\s+/g, ' ')
69
+ .trim()
70
+ }
71
+
72
+ /**
73
+ * Prepend the post's own title, body, and featured image as sections the
74
+ * audit already knows how to read (H1, opening prose, image slot).
75
+ *
76
+ * A `text` section carries title + body so we do not invent a phantom hero
77
+ * image slot. The featured image is a separate `feature` section, and only
78
+ * when one is set — otherwise `imageSlots` would count an empty media field
79
+ * and fail alt-text for an image that is not on the page.
80
+ */
81
+ export function withPostAuditSlots(sections: PageSection[], post?: PostAuditExtras): PageSection[] {
82
+ if (!post) return sections
83
+
84
+ const extras: PageSection[] = []
85
+ const title = post.title?.trim() ?? ''
86
+ const body = post.bodyHtml ? stripHtml(post.bodyHtml) : ''
87
+ if (title || body) {
88
+ extras.push({
89
+ id: POST_TITLE_SECTION_ID,
90
+ sectionType: 'text',
91
+ name: 'Post',
92
+ visible: true,
93
+ content: { heading: title, body },
94
+ settings: {},
95
+ })
96
+ }
97
+ const image = post.featuredImage?.trim() ?? ''
98
+ if (image) {
99
+ extras.push({
100
+ id: POST_IMAGE_SECTION_ID,
101
+ sectionType: 'feature',
102
+ name: 'Featured image',
103
+ visible: true,
104
+ content: { imageUrl: image, imageAlt: '' },
105
+ settings: {},
106
+ })
107
+ }
108
+ return extras.length ? [...extras, ...sections] : sections
44
109
  }
45
110
 
46
111
  export function buildPageAuditInput({
@@ -49,6 +114,7 @@ export function buildPageAuditInput({
49
114
  slug,
50
115
  record,
51
116
  fallbackTitle,
117
+ post,
52
118
  }: BuildAuditInputArgs): PageAuditInput {
53
119
  // Measure the title a searcher actually sees — the page meta title with the
54
120
  // site's template applied — not the raw field. Checking the raw field would
@@ -65,7 +131,7 @@ export function buildPageAuditInput({
65
131
  : meta.metaTitle.trim() || fallbackTitle || ''
66
132
 
67
133
  return {
68
- sections,
134
+ sections: withPostAuditSlots(sections, post),
69
135
  seo: {
70
136
  title: renderedTitle,
71
137
  description: meta.metaDescription,
@@ -1,140 +1,11 @@
1
1
  /**
2
- * Versioned postMessage protocol between the page editor (parent) and the
3
- * Next editing canvas (iframe). Namespaced with colons so it does not collide
4
- * with the legacy `prenta-preview-update` DocumentEdit bus.
2
+ * Field-path helpers shared by the editor canvas and the inspector.
5
3
  *
6
- * @see prenta docs/superpowers/specs/2026-07-29-visual-editing-design.md
4
+ * The old `prenta-preview:*` postMessage protocol is gone — View site opens a
5
+ * normal tab and does not talk back. `data-cms-field` on first-party section
6
+ * renderers still feeds the reflow guard and canvas field-focus.
7
7
  */
8
8
 
9
- export const PREVIEW_BRIDGE_VERSION = 1 as const
10
-
11
- /** Debounce for live section pushes while typing in the inspector. */
12
- export const PREVIEW_SECTIONS_DEBOUNCE_MS = 120
13
-
14
- export type PreviewBridgeMessageType =
15
- | 'prenta-preview:ready'
16
- | 'prenta-preview:select'
17
- | 'prenta-preview:highlight'
18
- | 'prenta-preview:sections'
19
- | 'prenta-preview:mode'
20
- | 'prenta-preview:edit'
21
- | 'prenta-preview:request-media'
22
-
23
- export interface PreviewBridgeEnvelope {
24
- type: PreviewBridgeMessageType
25
- v: typeof PREVIEW_BRIDGE_VERSION
26
- }
27
-
28
- export interface PreviewReadyMessage extends PreviewBridgeEnvelope {
29
- type: 'prenta-preview:ready'
30
- }
31
-
32
- export interface PreviewSelectMessage extends PreviewBridgeEnvelope {
33
- type: 'prenta-preview:select'
34
- sectionId: string
35
- fieldPath?: string
36
- }
37
-
38
- export interface PreviewHighlightMessage extends PreviewBridgeEnvelope {
39
- type: 'prenta-preview:highlight'
40
- sectionId: string | null
41
- }
42
-
43
- /**
44
- * Opaque JSON object on the wire. Uses `object` (not `Record<string, unknown>`)
45
- * so concrete editor interfaces like `SectionSettings` remain assignable.
46
- */
47
- export type PreviewObjectPayload = object
48
-
49
- /** Wire shape for live canvas updates — matches `PublicSection` / editor sections. */
50
- export interface PreviewSectionPayload {
51
- id: string
52
- sectionType: string
53
- name?: string
54
- visible?: boolean
55
- content?: PreviewObjectPayload
56
- settings?: PreviewObjectPayload
57
- unmanaged?: boolean
58
- }
59
-
60
- export interface PreviewSectionsMessage extends PreviewBridgeEnvelope {
61
- type: 'prenta-preview:sections'
62
- sections: PreviewSectionPayload[]
63
- }
64
-
65
- /** Admin → canvas: enable/disable inline editing affordances. */
66
- export interface PreviewModeMessage extends PreviewBridgeEnvelope {
67
- type: 'prenta-preview:mode'
68
- editing: boolean
69
- }
70
-
71
- /** Canvas → admin: text field edit from contenteditable. */
72
- export interface PreviewEditMessage extends PreviewBridgeEnvelope {
73
- type: 'prenta-preview:edit'
74
- sectionId: string
75
- fieldPath: string
76
- value: string
77
- }
78
-
79
- /** Canvas → admin: open media picker for an image field. */
80
- export interface PreviewRequestMediaMessage extends PreviewBridgeEnvelope {
81
- type: 'prenta-preview:request-media'
82
- sectionId: string
83
- fieldPath: string
84
- }
85
-
86
- export type PreviewBridgeMessage =
87
- | PreviewReadyMessage
88
- | PreviewSelectMessage
89
- | PreviewHighlightMessage
90
- | PreviewSectionsMessage
91
- | PreviewModeMessage
92
- | PreviewEditMessage
93
- | PreviewRequestMediaMessage
94
-
95
- export function isPreviewBridgeMessage(data: unknown): data is PreviewBridgeMessage {
96
- if (!data || typeof data !== 'object') return false
97
- const msg = data as Record<string, unknown>
98
- if (msg.v !== PREVIEW_BRIDGE_VERSION) return false
99
- if (typeof msg.type !== 'string' || !msg.type.startsWith('prenta-preview:')) return false
100
- return true
101
- }
102
-
103
- export function isPreviewEditMessage(data: unknown): data is PreviewEditMessage {
104
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:edit') return false
105
- return (
106
- typeof data.sectionId === 'string' &&
107
- data.sectionId.length > 0 &&
108
- typeof data.fieldPath === 'string' &&
109
- data.fieldPath.length > 0 &&
110
- typeof data.value === 'string'
111
- )
112
- }
113
-
114
- export function isPreviewRequestMediaMessage(data: unknown): data is PreviewRequestMediaMessage {
115
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:request-media') return false
116
- return (
117
- typeof data.sectionId === 'string' &&
118
- data.sectionId.length > 0 &&
119
- typeof data.fieldPath === 'string' &&
120
- data.fieldPath.length > 0
121
- )
122
- }
123
-
124
- export function isPreviewModeMessage(data: unknown): data is PreviewModeMessage {
125
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:mode') return false
126
- return typeof data.editing === 'boolean'
127
- }
128
-
129
- export function canvasOriginFromSrc(src: string | null | undefined): string | null {
130
- if (!src) return null
131
- try {
132
- return new URL(src, typeof window !== 'undefined' ? window.location.origin : undefined).origin
133
- } catch {
134
- return null
135
- }
136
- }
137
-
138
9
  /**
139
10
  * Map a dotted content field path (`lead`, `stats.0.num`) to the inspector
140
11
  * input id (`insp-field-lead`, `insp-field-stats-0-num`). Same string shape
@@ -181,29 +52,12 @@ export function companionDimensionPaths(fieldPath: string): {
181
52
  }
182
53
 
183
54
  /**
184
- * Media URL slots are not contenteditable. Prefer `data-cms-field-kind="media"`
185
- * on the canvas; fall back to common image field leaf names.
55
+ * Read `data-cms-field` from a canvas click target (or its ancestor).
56
+ * Used so a field click opens the accordion on the matching inspector input.
186
57
  */
187
- export function isMediaFieldPath(fieldPath: string, kindAttr?: string | null): boolean {
188
- if (kindAttr === 'media') return true
189
- if (kindAttr === 'text') return false
190
- const leaf = fieldPathLeaf(fieldPath)
191
- if (leaf === 'imageUrl' || leaf.endsWith('ImageUrl') || leaf === 'featuredImage') return true
192
- // Gallery rows use `images.N.url` (not link CTAs).
193
- return leaf === 'url' && fieldPath.includes('images.')
194
- }
195
-
196
- /** Strip editor chrome to the public wire shape before posting to the canvas. */
197
- export function toPreviewSectionPayload(
198
- section: PreviewSectionPayload & { internalLabel?: string },
199
- ): PreviewSectionPayload {
200
- return {
201
- id: section.id,
202
- sectionType: section.sectionType,
203
- name: section.name,
204
- visible: section.visible,
205
- content: section.content ?? {},
206
- settings: section.settings ?? {},
207
- unmanaged: section.unmanaged,
208
- }
58
+ export function fieldPathFromEventTarget(target: EventTarget | null): string | undefined {
59
+ if (!(target instanceof Element)) return undefined
60
+ const field = target.closest('[data-cms-field]')
61
+ const path = field?.getAttribute('data-cms-field')?.trim()
62
+ return path || undefined
209
63
  }
@@ -35,6 +35,11 @@ export interface PageVersion {
35
35
  author: VersionAuthor
36
36
  /** Full page content as of this version — what the canvas renders. */
37
37
  sections: PageSection[]
38
+ /** Post identity fields, when the snapshot is a post. */
39
+ title?: string
40
+ excerpt?: string
41
+ featuredImage?: string
42
+ body?: string
38
43
  }
39
44
 
40
45
  /** Versions sharing a calendar day, newest day first. */
@@ -87,6 +92,20 @@ function toAuthor(row: VersionApiRow): VersionAuthor {
87
92
  }
88
93
  }
89
94
 
95
+ function optionalString(value: unknown): string | undefined {
96
+ return typeof value === 'string' ? value : undefined
97
+ }
98
+
99
+ function optionalFeaturedImage(value: unknown): string | undefined {
100
+ if (typeof value === 'string') return value
101
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
102
+ const obj = value as Record<string, unknown>
103
+ const url = obj.url ?? obj.publicUrl ?? obj.src
104
+ if (typeof url === 'string') return url
105
+ }
106
+ return undefined
107
+ }
108
+
90
109
  export function toPageVersion(row: VersionApiRow): PageVersion {
91
110
  const data = row.data ?? {}
92
111
  return {
@@ -98,6 +117,10 @@ export function toPageVersion(row: VersionApiRow): PageVersion {
98
117
  // snapshot more strictly than the current page would make an unmanaged
99
118
  // section look like it was deleted in every version that contains one.
100
119
  sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
120
+ title: optionalString(data.title),
121
+ excerpt: optionalString(data.excerpt),
122
+ featuredImage: optionalFeaturedImage(data.featuredImage),
123
+ body: optionalString(data.body),
101
124
  }
102
125
  }
103
126
 
@@ -5,11 +5,16 @@ export type ViewSiteIntent = 'preview-draft' | 'view-live'
5
5
  export const NO_PUBLIC_SITE_URL_REASON =
6
6
  'Set the public site URL in Settings → SEO to open the live design.'
7
7
 
8
+ export const ADMIN_ORIGIN_SITE_URL_REASON =
9
+ 'This origin is the admin, not the public site. Set the public site URL in Settings → SEO.'
10
+
8
11
  export function resolveViewSiteChrome(input: {
9
12
  status: PageStatus
10
13
  dirty: boolean
11
14
  hasPublicSiteUrl: boolean
12
15
  hasIdentity: boolean
16
+ /** True when the configured site URL is the CMS host (Astro-split misconfig). */
17
+ matchesAdminOrigin?: boolean
13
18
  }): {
14
19
  viewEnabled: boolean
15
20
  viewDisabledReason?: string
@@ -21,12 +26,19 @@ export function resolveViewSiteChrome(input: {
21
26
  const primaryIntent: ViewSiteIntent =
22
27
  input.status === 'PUBLISHED' && !input.dirty ? 'view-live' : 'preview-draft'
23
28
  const shareVisible = input.hasIdentity && unpublished
24
- const viewEnabled = input.hasIdentity && input.hasPublicSiteUrl
29
+ const usablePublicUrl = input.hasPublicSiteUrl && !input.matchesAdminOrigin
30
+ const viewEnabled = input.hasIdentity && usablePublicUrl
31
+
32
+ let viewDisabledReason: string | undefined
33
+ if (input.hasIdentity && !input.hasPublicSiteUrl) {
34
+ viewDisabledReason = NO_PUBLIC_SITE_URL_REASON
35
+ } else if (input.hasIdentity && input.matchesAdminOrigin) {
36
+ viewDisabledReason = ADMIN_ORIGIN_SITE_URL_REASON
37
+ }
25
38
 
26
39
  return {
27
40
  viewEnabled,
28
- viewDisabledReason:
29
- input.hasIdentity && !input.hasPublicSiteUrl ? NO_PUBLIC_SITE_URL_REASON : undefined,
41
+ viewDisabledReason,
30
42
  primaryIntent,
31
43
  showViewLive: input.hasIdentity && input.status === 'PUBLISHED' && input.dirty,
32
44
  shareVisible,
@@ -13,7 +13,7 @@ interface EditorCanvasProps {
13
13
  selectedId: string | null
14
14
  breakpoint: BreakpointId
15
15
  zoom?: Zoom
16
- onSelect: (id: string) => void
16
+ onSelect: (id: string, fieldPath?: string) => void
17
17
  onScaleChange?: (scale: number) => void
18
18
  onCanvasElement?: (el: HTMLElement | null) => void
19
19
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
@@ -55,6 +55,8 @@ interface EditorTopBarProps {
55
55
  onShare?: () => void
56
56
  /** When false, View site and Share are disabled with the SEO settings reason. */
57
57
  hasPublicSiteUrl?: boolean
58
+ /** When true, View site and Share stay disabled — the URL is the CMS host. */
59
+ matchesAdminOrigin?: boolean
58
60
  /**
59
61
  * Toggle the version history panel. When omitted the History button is
60
62
  * hidden — e.g. for unsaved documents that have no versions yet.
@@ -122,6 +124,7 @@ export function EditorTopBar({
122
124
  onViewOnSite,
123
125
  onShare,
124
126
  hasPublicSiteUrl,
127
+ matchesAdminOrigin,
125
128
  onHistory,
126
129
  historyOpen = false,
127
130
  onOpenSettings,
@@ -142,6 +145,7 @@ export function EditorTopBar({
142
145
  status,
143
146
  dirty,
144
147
  hasPublicSiteUrl: Boolean(hasPublicSiteUrl),
148
+ matchesAdminOrigin: Boolean(matchesAdminOrigin),
145
149
  hasIdentity: true,
146
150
  })
147
151
  : null
@@ -227,8 +231,8 @@ export function EditorTopBar({
227
231
  <button
228
232
  type="button"
229
233
  onClick={onOpenSettings}
230
- aria-label="Page settings"
231
- title="Page settings"
234
+ aria-label={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
235
+ title={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
232
236
  className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2"
233
237
  >
234
238
  <Settings2 className="h-4 w-4" aria-hidden />
@@ -27,13 +27,11 @@ import {
27
27
  savePageDraft,
28
28
  toggleSectionVisibility,
29
29
  updateSectionContent,
30
- updateSectionContentAtPath,
31
30
  updateSectionMeta,
32
31
  validatePage,
33
32
  type EditorPage,
34
33
  type ValidationResult,
35
34
  } from '../../lib/page-editor-service.js'
36
- import { MediaPickerModal } from '../../components/MediaPickerModal.js'
37
35
  import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
38
36
  import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
39
37
  import type { ViewSiteIntent } from '../../lib/view-site-chrome.js'
@@ -61,7 +59,7 @@ import { SectionEditForm } from './SectionEditForm.js'
61
59
  import { PageSettingsModal } from './PageSettingsModal.js'
62
60
  import { ValidationSummary } from './ValidationSummary.js'
63
61
  import { buildEditorContentHtml } from '../../lib/editor-seo-content.js'
64
- import { companionDimensionPaths, inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
62
+ import { inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
65
63
 
66
64
  interface PageSectionEditorProps {
67
65
  /** undefined when creating a new page (`/pages/new`) */
@@ -134,13 +132,6 @@ export function PageSectionEditor({
134
132
  const [selectedId, setSelectedId] = useState<string | null>(null)
135
133
  /** Canvas field click — focused after the inspector mounts for `selectedId`. */
136
134
  const [pendingFocusPath, setPendingFocusPath] = useState<string | null>(null)
137
- /** Bump to skip the next `prenta-preview:sections` echo after a canvas text edit. */
138
- const [suppressSectionsEcho, setSuppressSectionsEcho] = useState(0)
139
- /** Canvas image Replace — opens MediaPickerModal for this field path. */
140
- const [canvasMediaTarget, setCanvasMediaTarget] = useState<{
141
- sectionId: string
142
- fieldPath: string
143
- } | null>(null)
144
135
  const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
145
136
  const [zoom, setZoom] = useState<Zoom>('fit')
146
137
  /**
@@ -227,7 +218,7 @@ export function PageSectionEditor({
227
218
 
228
219
  const { canEdit, canPublish, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
229
220
  const customRenderers = useAdminSectionRenderers()
230
- const { siteUrl: publicSiteUrl } = usePublicSiteUrl(config?.seo?.siteUrl)
221
+ const { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
231
222
 
232
223
  // A misconfigured `pages` collection (missing the fields the editor
233
224
  // writes) would silently drop content on save — detect it up front.
@@ -716,39 +707,14 @@ export function PageSectionEditor({
716
707
  // rail has no close affordance at all — the chevron and the row both
717
708
  // re-select what is already selected and nothing happens.
718
709
  //
719
- // Except when a fieldPath came with it: that is the site preview saying
720
- // "the editor clicked THIS field", which must open and focus it. Making
721
- // that toggle would close the form on the second click of a field the
722
- // user is trying to edit.
710
+ // Except when a fieldPath came with it: a canvas field click must open
711
+ // and focus that input. Toggling would close the form on the second
712
+ // click of a field the user is trying to inspect.
723
713
  if (fieldPath) return id
724
714
  return current === id ? null : id
725
715
  })
726
716
  }, [])
727
717
 
728
- // `effectiveCanEdit`, not `canEdit`: the site preview is a second editing
729
- // surface, so a role check alone let it write through both the version-review
730
- // lock and the builder-owned lock the rest of the editor honours.
731
- const handleCanvasEdit = useCallback(
732
- (edit: { sectionId: string; fieldPath: string; value: string }) => {
733
- if (!effectiveCanEdit) return
734
- setSuppressSectionsEcho((n) => n + 1)
735
- mutateSections((s) =>
736
- updateSectionContentAtPath(s, edit.sectionId, edit.fieldPath, edit.value),
737
- )
738
- setSelectedId(edit.sectionId)
739
- },
740
- [effectiveCanEdit, mutateSections],
741
- )
742
-
743
- const handleCanvasRequestMedia = useCallback(
744
- (request: { sectionId: string; fieldPath: string }) => {
745
- if (!effectiveCanEdit) return
746
- setSelectedId(request.sectionId)
747
- setCanvasMediaTarget(request)
748
- },
749
- [effectiveCanEdit],
750
- )
751
-
752
718
  // After a canvas field click, focus the matching inspector input once painted.
753
719
  useEffect(() => {
754
720
  if (!pendingFocusPath || !selectedId) return
@@ -878,6 +844,7 @@ export function PageSectionEditor({
878
844
  onViewOnSite={page.id && page.slug ? handleViewOnSite : undefined}
879
845
  onShare={page.id ? handleShare : undefined}
880
846
  hasPublicSiteUrl={Boolean(publicSiteUrl)}
847
+ matchesAdminOrigin={matchesAdminOrigin}
881
848
  historyOpen={historyOpen}
882
849
  onHistory={page.id ? () => (historyOpen ? closeHistory() : openHistory()) : undefined}
883
850
  onOpenSettings={() => setSettingsOpen(true)}
@@ -1114,30 +1081,6 @@ export function PageSectionEditor({
1114
1081
  cancelLabel="Stay"
1115
1082
  destructive
1116
1083
  />
1117
-
1118
- <MediaPickerModal
1119
- open={canvasMediaTarget !== null}
1120
- onClose={() => setCanvasMediaTarget(null)}
1121
- accept="image/*"
1122
- onSelectItem={(item) => {
1123
- if (!canvasMediaTarget) return
1124
- const { sectionId, fieldPath } = canvasMediaTarget
1125
- const { widthPath, heightPath } = companionDimensionPaths(fieldPath)
1126
- // Media Replace must echo sections so the canvas image updates.
1127
- mutateSections((s) => {
1128
- let next = updateSectionContentAtPath(s, sectionId, fieldPath, item.url)
1129
- if (typeof item.width === 'number') {
1130
- next = updateSectionContentAtPath(next, sectionId, widthPath, item.width)
1131
- }
1132
- if (typeof item.height === 'number') {
1133
- next = updateSectionContentAtPath(next, sectionId, heightPath, item.height)
1134
- }
1135
- return next
1136
- })
1137
- setCanvasMediaTarget(null)
1138
- }}
1139
- onSelect={() => setCanvasMediaTarget(null)}
1140
- />
1141
1084
  </ErrorBoundary>
1142
1085
  )
1143
1086
  }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Puzzle } from 'lucide-react'
4
+ import { fieldPathFromEventTarget } from '../../../lib/preview-bridge.js'
4
5
  import type { PageSection } from '../../../lib/page-editor-service.js'
5
6
  import { ErrorBoundary } from '../../../components/ErrorBoundary.js'
6
7
  import { getSectionType } from '../section-types.js'
@@ -163,7 +164,7 @@ export interface SectionRendererProps {
163
164
  * renders hidden sections muted instead of removing them. */
164
165
  editable?: boolean
165
166
  selected?: boolean
166
- onSelect?: (id: string) => void
167
+ onSelect?: (id: string, fieldPath?: string) => void
167
168
  /** Lets the canvas register the DOM node for scroll-to-section. */
168
169
  registerRef?: (id: string, el: HTMLElement | null) => void
169
170
  /** Live post data for post-oriented section renderers (pages omit this). */
@@ -234,7 +235,9 @@ export function SectionRenderer({
234
235
  ? 'outline-transparent hover:outline hover:outline-[color-mix(in_oklab,var(--acc)_40%,transparent)]'
235
236
  : 'outline-transparent'
236
237
  }`}
237
- onClick={interactive ? () => onSelect?.(section.id) : undefined}
238
+ onClick={
239
+ interactive ? (e) => onSelect?.(section.id, fieldPathFromEventTarget(e.target)) : undefined
240
+ }
238
241
  // Selection is a convenience in the editor; the section list is the
239
242
  // primary, fully-accessible control surface, so this stays a region.
240
243
  aria-label={
@@ -17,13 +17,26 @@ interface PostEditorCanvasProps {
17
17
  selectedId: string | null
18
18
  viewport: ViewportId
19
19
  zoom?: Zoom
20
- onSelect: (id: string) => void
20
+ onSelect: (id: string, fieldPath?: string) => void
21
21
  onScaleChange?: (scale: number) => void
22
22
  onCanvasElement?: (el: HTMLElement | null) => void
23
23
  /** Add-section CTA on the empty canvas. Omitted by design-owned editors. */
24
24
  onAddSection?: () => void
25
25
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
26
26
  structuralTypes?: string[]
27
+ /**
28
+ * Read-only render of a past version. Suppresses selection and the editing
29
+ * affordances, and turns on the changed-section outlines.
30
+ */
31
+ reviewing?: boolean
32
+ /** Sections that differ from the version before the one being reviewed. */
33
+ changedSectionIds?: ReadonlySet<string>
34
+ /**
35
+ * Rendered in place of the editing tip / structure notice while reviewing.
36
+ * Passed in rather than built here because the review bar's actions belong
37
+ * to the editor's history state, which the canvas knows nothing about.
38
+ */
39
+ reviewBar?: React.ReactNode
27
40
  }
28
41
 
29
42
  /**
@@ -51,6 +64,9 @@ export function PostEditorCanvas({
51
64
  onCanvasElement,
52
65
  onAddSection,
53
66
  structuralTypes = [],
67
+ reviewing = false,
68
+ changedSectionIds,
69
+ reviewBar,
54
70
  }: PostEditorCanvasProps) {
55
71
  const registerRef = useSectionRefs(selectedId)
56
72
 
@@ -62,15 +78,19 @@ export function PostEditorCanvas({
62
78
  onScaleChange={onScaleChange}
63
79
  onCanvasElement={onCanvasElement}
64
80
  chrome={
65
- <>
66
- {sections.length > 0 && <CanvasEditTip />}
67
- <StructureNotice structuralTypes={structuralTypes} />
68
- </>
81
+ reviewing ? (
82
+ reviewBar
83
+ ) : (
84
+ <>
85
+ {sections.length > 0 && <CanvasEditTip />}
86
+ <StructureNotice structuralTypes={structuralTypes} />
87
+ </>
88
+ )
69
89
  }
70
90
  >
71
91
  <PostHeader config={header} context={context} />
72
92
  {sections.length === 0 ? (
73
- <EmptyBody onAddSection={onAddSection} />
93
+ <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
74
94
  ) : (
75
95
  sections.map((section) => (
76
96
  <SectionRenderer
@@ -78,6 +98,8 @@ export function PostEditorCanvas({
78
98
  section={section}
79
99
  context={context}
80
100
  editable
101
+ reviewing={reviewing}
102
+ changed={changedSectionIds?.has(section.id) ?? false}
81
103
  selected={section.id === selectedId}
82
104
  onSelect={onSelect}
83
105
  registerRef={registerRef}