@prenta/admin 1.0.0 → 1.2.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 (149) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +5 -56
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/breakpoint-controls.render.test.js +4 -4
  6. package/dist/__tests__/components/breakpoint-controls.render.test.js.map +1 -1
  7. package/dist/__tests__/lib/canvas-edit-chrome.test.js +1 -22
  8. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -1
  9. package/dist/__tests__/lib/open-public-site-tab.test.d.ts +2 -0
  10. package/dist/__tests__/lib/open-public-site-tab.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/open-public-site-tab.test.js +64 -0
  12. package/dist/__tests__/lib/open-public-site-tab.test.js.map +1 -0
  13. package/dist/__tests__/lib/preview-bridge.test.js +19 -79
  14. package/dist/__tests__/lib/preview-bridge.test.js.map +1 -1
  15. package/dist/__tests__/lib/view-site-chrome.test.d.ts +2 -0
  16. package/dist/__tests__/lib/view-site-chrome.test.d.ts.map +1 -0
  17. package/dist/__tests__/lib/view-site-chrome.test.js +69 -0
  18. package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -0
  19. package/dist/__tests__/router/admin-routes.test.js +4 -0
  20. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  21. package/dist/__tests__/views/canvas-review-mode.render.test.js +11 -1
  22. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -1
  23. package/dist/__tests__/views/canvas-surface-shared.test.js +0 -16
  24. package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -1
  25. package/dist/__tests__/views/editor-top-bar.render.test.js +52 -22
  26. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  27. package/dist/__tests__/views/page-section-editor.test.js +53 -18
  28. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  29. package/dist/__tests__/views/post-section-editor.test.js +23 -0
  30. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  31. package/dist/components/BreakpointPicker.d.ts +3 -2
  32. package/dist/components/BreakpointPicker.d.ts.map +1 -1
  33. package/dist/components/BreakpointPicker.js +5 -3
  34. package/dist/components/BreakpointPicker.js.map +1 -1
  35. package/dist/components/CanvasEditTip.js +1 -1
  36. package/dist/components/CanvasEditTip.js.map +1 -1
  37. package/dist/lib/canvas-edit-chrome.d.ts +0 -17
  38. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -1
  39. package/dist/lib/canvas-edit-chrome.js +0 -62
  40. package/dist/lib/canvas-edit-chrome.js.map +1 -1
  41. package/dist/lib/editor-preview.d.ts +4 -1
  42. package/dist/lib/editor-preview.d.ts.map +1 -1
  43. package/dist/lib/editor-preview.js +4 -1
  44. package/dist/lib/editor-preview.js.map +1 -1
  45. package/dist/lib/open-public-site-tab.d.ts +14 -0
  46. package/dist/lib/open-public-site-tab.d.ts.map +1 -0
  47. package/dist/lib/open-public-site-tab.js +46 -0
  48. package/dist/lib/open-public-site-tab.js.map +1 -0
  49. package/dist/lib/preview-bridge.d.ts +7 -74
  50. package/dist/lib/preview-bridge.d.ts.map +1 -1
  51. package/dist/lib/preview-bridge.js +12 -73
  52. package/dist/lib/preview-bridge.js.map +1 -1
  53. package/dist/lib/view-site-chrome.d.ts +19 -0
  54. package/dist/lib/view-site-chrome.d.ts.map +1 -0
  55. package/dist/lib/view-site-chrome.js +24 -0
  56. package/dist/lib/view-site-chrome.js.map +1 -0
  57. package/dist/router/admin-routes.d.ts.map +1 -1
  58. package/dist/router/admin-routes.js +0 -2
  59. package/dist/router/admin-routes.js.map +1 -1
  60. package/dist/views/page-editor/EditorCanvas.d.ts +2 -3
  61. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  62. package/dist/views/page-editor/EditorCanvas.js +2 -2
  63. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  64. package/dist/views/page-editor/EditorTopBar.d.ts +15 -17
  65. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  66. package/dist/views/page-editor/EditorTopBar.js +20 -21
  67. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  68. package/dist/views/page-editor/PageSectionEditor.d.ts +1 -7
  69. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  70. package/dist/views/page-editor/PageSectionEditor.js +36 -141
  71. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  72. package/dist/views/page-editor/StructureNotice.d.ts +2 -5
  73. package/dist/views/page-editor/StructureNotice.d.ts.map +1 -1
  74. package/dist/views/page-editor/StructureNotice.js +6 -10
  75. package/dist/views/page-editor/StructureNotice.js.map +1 -1
  76. package/dist/views/page-editor/sections/SectionRenderer.d.ts +1 -1
  77. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  78. package/dist/views/page-editor/sections/SectionRenderer.js +2 -1
  79. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  80. package/dist/views/post-editor/PostEditorCanvas.d.ts +2 -4
  81. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  82. package/dist/views/post-editor/PostEditorCanvas.js +3 -3
  83. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  84. package/dist/views/post-editor/PostSectionEditor.d.ts +1 -15
  85. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostSectionEditor.js +36 -128
  87. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  88. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  89. package/dist/views/settings/SeoSettingsTab.js +2 -1
  90. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  91. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  92. package/dist/views/settings/SiteInformationCard.js +3 -1
  93. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  94. package/package.json +6 -6
  95. package/src/AdminRoot.tsx +0 -70
  96. package/src/__tests__/components/breakpoint-controls.render.test.tsx +6 -4
  97. package/src/__tests__/lib/canvas-edit-chrome.test.ts +0 -27
  98. package/src/__tests__/lib/open-public-site-tab.test.ts +74 -0
  99. package/src/__tests__/lib/preview-bridge.test.ts +21 -110
  100. package/src/__tests__/lib/view-site-chrome.test.ts +80 -0
  101. package/src/__tests__/router/admin-routes.test.ts +5 -0
  102. package/src/__tests__/views/canvas-review-mode.render.test.tsx +14 -1
  103. package/src/__tests__/views/canvas-surface-shared.test.ts +0 -19
  104. package/src/__tests__/views/editor-top-bar.render.test.tsx +104 -22
  105. package/src/__tests__/views/page-section-editor.test.tsx +65 -22
  106. package/src/__tests__/views/post-section-editor.test.tsx +35 -0
  107. package/src/components/BreakpointPicker.tsx +6 -3
  108. package/src/components/CanvasEditTip.tsx +2 -2
  109. package/src/lib/canvas-edit-chrome.ts +0 -64
  110. package/src/lib/editor-preview.ts +4 -1
  111. package/src/lib/open-public-site-tab.ts +57 -0
  112. package/src/lib/preview-bridge.ts +11 -157
  113. package/src/lib/view-site-chrome.ts +46 -0
  114. package/src/router/admin-routes.ts +0 -2
  115. package/src/styles/theme.css +4 -4
  116. package/src/views/page-editor/EditorCanvas.tsx +2 -7
  117. package/src/views/page-editor/EditorTopBar.tsx +85 -82
  118. package/src/views/page-editor/PageSectionEditor.tsx +83 -220
  119. package/src/views/page-editor/StructureNotice.tsx +7 -42
  120. package/src/views/page-editor/sections/SectionRenderer.tsx +5 -2
  121. package/src/views/post-editor/PostEditorCanvas.tsx +3 -9
  122. package/src/views/post-editor/PostSectionEditor.tsx +71 -195
  123. package/src/views/settings/SeoSettingsTab.tsx +21 -0
  124. package/src/views/settings/SiteInformationCard.tsx +7 -0
  125. package/dist/__tests__/views/use-site-preview.render.test.d.ts +0 -2
  126. package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +0 -1
  127. package/dist/__tests__/views/use-site-preview.render.test.js +0 -120
  128. package/dist/__tests__/views/use-site-preview.render.test.js.map +0 -1
  129. package/dist/views/page-editor/PagePreview.d.ts +0 -13
  130. package/dist/views/page-editor/PagePreview.d.ts.map +0 -1
  131. package/dist/views/page-editor/PagePreview.js +0 -46
  132. package/dist/views/page-editor/PagePreview.js.map +0 -1
  133. package/dist/views/page-editor/SitePreviewFrame.d.ts +0 -43
  134. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +0 -1
  135. package/dist/views/page-editor/SitePreviewFrame.js +0 -165
  136. package/dist/views/page-editor/SitePreviewFrame.js.map +0 -1
  137. package/dist/views/page-editor/useSitePreview.d.ts +0 -47
  138. package/dist/views/page-editor/useSitePreview.d.ts.map +0 -1
  139. package/dist/views/page-editor/useSitePreview.js +0 -96
  140. package/dist/views/page-editor/useSitePreview.js.map +0 -1
  141. package/dist/views/post-editor/PostPreview.d.ts +0 -14
  142. package/dist/views/post-editor/PostPreview.d.ts.map +0 -1
  143. package/dist/views/post-editor/PostPreview.js +0 -63
  144. package/dist/views/post-editor/PostPreview.js.map +0 -1
  145. package/src/__tests__/views/use-site-preview.render.test.tsx +0 -164
  146. package/src/views/page-editor/PagePreview.tsx +0 -105
  147. package/src/views/page-editor/SitePreviewFrame.tsx +0 -250
  148. package/src/views/page-editor/useSitePreview.ts +0 -158
  149. package/src/views/post-editor/PostPreview.tsx +0 -125
@@ -15,7 +15,6 @@ const baseProps = {
15
15
  viewport: 'wide' as const,
16
16
  onViewport: vi.fn(),
17
17
  onBack: vi.fn(),
18
- onPreview: vi.fn(),
19
18
  onOpenSettings: vi.fn(),
20
19
  onSaveDraft: vi.fn(),
21
20
  onPublish: vi.fn(),
@@ -88,25 +87,112 @@ describe('EditorTopBar publish gating', () => {
88
87
  })
89
88
  })
90
89
 
91
- describe('EditorTopBar view-on-site vs view-live', () => {
92
- it('labels the external-link button "View draft on site" for a draft', () => {
93
- render(<EditorTopBar {...baseProps} status="DRAFT" onViewOnSite={vi.fn()} />)
94
- expect(screen.getByRole('button', { name: 'View draft on site' })).toBeTruthy()
95
- expect(screen.queryByRole('button', { name: 'View live page' })).toBeNull()
90
+ describe('EditorTopBar view site / share', () => {
91
+ it('does not render the eye or the globe', () => {
92
+ render(
93
+ <EditorTopBar {...baseProps} onViewOnSite={vi.fn()} onShare={vi.fn()} hasPublicSiteUrl />,
94
+ )
95
+ expect(screen.queryByRole('button', { name: /Preview page|Preview structure/ })).toBeNull()
96
96
  })
97
97
 
98
- it('labels it "View live page" for a published document and calls the handler', () => {
98
+ it('labels View site on a draft and calls with preview-draft', () => {
99
99
  const onViewOnSite = vi.fn()
100
- render(<EditorTopBar {...baseProps} status="PUBLISHED" onViewOnSite={onViewOnSite} />)
101
- const btn = screen.getByRole('button', { name: 'View live page' })
102
- fireEvent.click(btn)
103
- expect(onViewOnSite).toHaveBeenCalledOnce()
104
- expect(screen.queryByRole('button', { name: 'View draft on site' })).toBeNull()
100
+ render(
101
+ <EditorTopBar
102
+ {...baseProps}
103
+ status="DRAFT"
104
+ onViewOnSite={onViewOnSite}
105
+ onShare={vi.fn()}
106
+ hasPublicSiteUrl
107
+ />,
108
+ )
109
+ fireEvent.click(screen.getByRole('button', { name: 'View site' }))
110
+ expect(onViewOnSite).toHaveBeenCalledWith('preview-draft')
111
+ expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
112
+ })
113
+
114
+ it('labels View live on a clean published document and hides Share', () => {
115
+ const onViewOnSite = vi.fn()
116
+ render(
117
+ <EditorTopBar
118
+ {...baseProps}
119
+ status="PUBLISHED"
120
+ onViewOnSite={onViewOnSite}
121
+ onShare={vi.fn()}
122
+ hasPublicSiteUrl
123
+ />,
124
+ )
125
+ fireEvent.click(screen.getByRole('button', { name: 'View live' }))
126
+ expect(onViewOnSite).toHaveBeenCalledWith('view-live')
127
+ expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
128
+ })
129
+
130
+ it('keeps View live as a second action when published and dirty', () => {
131
+ const onViewOnSite = vi.fn()
132
+ render(
133
+ <EditorTopBar
134
+ {...baseProps}
135
+ status="PUBLISHED"
136
+ dirty
137
+ onViewOnSite={onViewOnSite}
138
+ onShare={vi.fn()}
139
+ hasPublicSiteUrl
140
+ />,
141
+ )
142
+ fireEvent.click(screen.getByRole('button', { name: 'View site' }))
143
+ expect(onViewOnSite).toHaveBeenCalledWith('preview-draft')
144
+ // Radix DropdownMenu opens on pointerdown (not click), which happy-dom
145
+ // doesn't synthesize from fireEvent.click — open via keyboard instead.
146
+ fireEvent.keyDown(screen.getByRole('button', { name: 'More view options' }), {
147
+ key: 'Enter',
148
+ })
149
+ fireEvent.click(screen.getByRole('menuitem', { name: 'View live' }))
150
+ expect(onViewOnSite).toHaveBeenCalledWith('view-live')
151
+ expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
152
+ })
153
+
154
+ it('disables View site and Share when the site URL is the admin origin', () => {
155
+ render(
156
+ <EditorTopBar
157
+ {...baseProps}
158
+ onViewOnSite={vi.fn()}
159
+ onShare={vi.fn()}
160
+ hasPublicSiteUrl
161
+ matchesAdminOrigin
162
+ />,
163
+ )
164
+ const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
165
+ const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
166
+ expect(view.disabled).toBe(true)
167
+ expect(share.disabled).toBe(true)
168
+ expect(view.getAttribute('title')).toBe(
169
+ 'This origin is the admin, not the public site. Set the public site URL in Settings → SEO.',
170
+ )
171
+ })
172
+
173
+ it('disables View site and Share when the public site URL is missing', () => {
174
+ render(
175
+ <EditorTopBar
176
+ {...baseProps}
177
+ onViewOnSite={vi.fn()}
178
+ onShare={vi.fn()}
179
+ hasPublicSiteUrl={false}
180
+ />,
181
+ )
182
+ const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
183
+ const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
184
+ expect(view.disabled).toBe(true)
185
+ expect(share.disabled).toBe(true)
186
+ expect(view.getAttribute('title')).toBe(
187
+ 'Set the public site URL in Settings → SEO to open the live design.',
188
+ )
105
189
  })
106
190
 
107
- it('hides the button when onViewOnSite is omitted (unsaved doc)', () => {
191
+ it('hides View site and Share when onViewOnSite is omitted (unsaved doc)', () => {
108
192
  render(<EditorTopBar {...baseProps} status="PUBLISHED" />)
109
- expect(screen.queryByRole('button', { name: /View (live|draft)/ })).toBeNull()
193
+ expect(screen.queryByRole('button', { name: 'View site' })).toBeNull()
194
+ expect(screen.queryByRole('button', { name: 'View live' })).toBeNull()
195
+ expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
110
196
  })
111
197
  })
112
198
 
@@ -133,19 +219,15 @@ describe('EditorTopBar history button', () => {
133
219
  render(
134
220
  <EditorTopBar
135
221
  {...baseProps}
222
+ status="PUBLISHED"
223
+ dirty
224
+ hasPublicSiteUrl
136
225
  onHistory={vi.fn()}
137
226
  onViewOnSite={vi.fn()}
138
- onToggleSitePreview={vi.fn()}
139
227
  />,
140
228
  )
141
229
 
142
- for (const name of [
143
- 'Page settings',
144
- 'Version history',
145
- 'Preview page',
146
- 'Inline site preview',
147
- 'View draft on site',
148
- ]) {
230
+ for (const name of ['Page settings', 'Version history', 'More view options']) {
149
231
  const button = screen.getByRole('button', { name })
150
232
  expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
151
233
  }
@@ -3,11 +3,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
4
 
5
5
  const fetchPageForEditor = vi.fn()
6
+ const savePageDraft = vi.fn(async (page: unknown) => page)
6
7
  vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
7
8
  const actual = await importOriginal<typeof import('../../lib/page-editor-service.js')>()
8
9
  return {
9
10
  ...actual,
10
- fetchPageForEditor: (...args: unknown[]) => fetchPageForEditor(...args),
11
+ fetchPageForEditor,
12
+ savePageDraft,
13
+ }
14
+ })
15
+ vi.mock('../../lib/seo-service.js', async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import('../../lib/seo-service.js')>()
17
+ return {
18
+ ...actual,
19
+ updateSeoFields: vi.fn(async () => ({})),
11
20
  }
12
21
  })
13
22
 
@@ -32,6 +41,7 @@ const validConfig = {
32
41
 
33
42
  afterEach(() => {
34
43
  fetchPageForEditor.mockReset()
44
+ savePageDraft.mockClear()
35
45
  })
36
46
 
37
47
  describe('PageSectionEditor schema guard', () => {
@@ -112,9 +122,8 @@ describe('PageSectionEditor structure mode', () => {
112
122
 
113
123
  await screen.findByText('Sections')
114
124
  expect(screen.queryByText(/Structure view/)).toBeNull()
115
- // "View on site" stays an icon-only button.
116
- expect(screen.getByRole('button', { name: 'View draft on site' })).toBeTruthy()
117
- expect(screen.getByRole('button', { name: 'Preview page' })).toBeTruthy()
125
+ expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
126
+ expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
118
127
  })
119
128
 
120
129
  it('shows the structure notice and promotes "View on site" for custom section types', async () => {
@@ -130,10 +139,7 @@ describe('PageSectionEditor structure mode', () => {
130
139
 
131
140
  await screen.findByText('Sections')
132
141
  expect(screen.getByText(/Structure view/)).toBeTruthy()
133
- // Promoted to a labeled button the primary design preview.
134
- expect(screen.getByRole('button', { name: /View on site/ })).toBeTruthy()
135
- // Eye preview is relabeled to set expectations.
136
- expect(screen.getByRole('button', { name: /Preview structure/ })).toBeTruthy()
142
+ expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
137
143
  })
138
144
 
139
145
  it('stays out of structure mode when a custom renderer is registered for the type', async () => {
@@ -161,7 +167,7 @@ describe('PageSectionEditor structure mode', () => {
161
167
  // structural for this page.
162
168
  expect(screen.getByTestId('opal-canvas')).toBeTruthy()
163
169
  expect(screen.queryByText(/Structure view/)).toBeNull()
164
- expect(screen.getByRole('button', { name: 'View draft on site' })).toBeTruthy()
170
+ expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
165
171
  })
166
172
 
167
173
  it('exposes a version history button for saved pages', async () => {
@@ -207,12 +213,22 @@ describe('PageSectionEditor section disclosure', () => {
207
213
  expect(screen.queryByLabelText('Section name')).toBeNull()
208
214
  })
209
215
 
210
- it('keeps the row open when the site preview points at one of its fields', async () => {
216
+ it('keeps the row open when a canvas field click targets the same section', async () => {
211
217
  // A `fieldPath` means "the editor clicked THIS field" — toggling there
212
- // would close the form on the second click of a field being edited.
213
- fetchPageForEditor.mockResolvedValue(
214
- loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
215
- )
218
+ // would close the form on the second click of a field being inspected.
219
+ fetchPageForEditor.mockResolvedValue({
220
+ ...loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
221
+ sections: [
222
+ {
223
+ id: 's1',
224
+ sectionType: 'hero',
225
+ name: 'Hero',
226
+ visible: true,
227
+ content: { heading: 'Welcome' },
228
+ settings: {},
229
+ },
230
+ ],
231
+ })
216
232
  render(
217
233
  <PageSectionEditor documentId="p1" config={validConfig} onNavigate={() => {}} session={{}} />,
218
234
  )
@@ -221,15 +237,42 @@ describe('PageSectionEditor section disclosure', () => {
221
237
  fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
222
238
  await screen.findByRole('button', { name: 'Close Hero' })
223
239
 
224
- // Same section, arriving with a field path, twice.
225
- for (let i = 0; i < 2; i++) {
226
- window.dispatchEvent(
227
- new MessageEvent('message', {
228
- data: { v: 1, type: 'prenta-preview:select', sectionId: 's1', fieldPath: 'heading' },
229
- }),
230
- )
231
- }
240
+ const section = document.querySelector('[data-section-id="s1"]')
241
+ expect(section).toBeTruthy()
242
+ const field = document.createElement('h2')
243
+ field.setAttribute('data-cms-field', 'heading')
244
+ field.textContent = 'Welcome'
245
+ section?.appendChild(field)
246
+ fireEvent.click(field)
247
+ fireEvent.click(field)
232
248
 
233
249
  expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
234
250
  })
235
251
  })
252
+
253
+ describe('PageSectionEditor Share', () => {
254
+ const configWithSite = { ...validConfig, seo: { siteUrl: 'https://example.com' } }
255
+
256
+ it('saves a dirty draft before opening Share', async () => {
257
+ const page = loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }])
258
+ fetchPageForEditor.mockResolvedValue(page)
259
+ savePageDraft.mockResolvedValue(page)
260
+
261
+ render(
262
+ <PageSectionEditor
263
+ documentId="p1"
264
+ config={configWithSite}
265
+ onNavigate={() => {}}
266
+ session={{}}
267
+ />,
268
+ )
269
+ await screen.findByText('Sections')
270
+
271
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
272
+ fireEvent.change(await screen.findByLabelText('Section name'), { target: { value: 'Intro' } })
273
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }))
274
+
275
+ await waitFor(() => expect(savePageDraft).toHaveBeenCalledOnce())
276
+ expect(await screen.findByRole('dialog', { name: 'Share preview link' })).toBeTruthy()
277
+ })
278
+ })
@@ -4,12 +4,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
4
 
5
5
  const fetchPostForEditor = vi.fn()
6
6
  const fetchTemplateForEditor = vi.fn()
7
+ const savePostDraft = vi.fn()
7
8
  vi.mock('../../lib/post-editor-service.js', async (importOriginal) => {
8
9
  const actual = await importOriginal<typeof import('../../lib/post-editor-service.js')>()
9
10
  return {
10
11
  ...actual,
11
12
  fetchPostForEditor: (...args: unknown[]) => fetchPostForEditor(...args),
12
13
  fetchTemplateForEditor: (...args: unknown[]) => fetchTemplateForEditor(...args),
14
+ savePostDraft: (...args: unknown[]) => savePostDraft(...args),
13
15
  }
14
16
  })
15
17
 
@@ -62,6 +64,7 @@ function loadedPost(
62
64
  afterEach(() => {
63
65
  fetchPostForEditor.mockReset()
64
66
  fetchTemplateForEditor.mockReset()
67
+ savePostDraft.mockReset()
65
68
  })
66
69
 
67
70
  describe('PostSectionEditor schema guard', () => {
@@ -126,6 +129,9 @@ describe('PostSectionEditor new post', () => {
126
129
  )
127
130
  await screen.findByText('Sections')
128
131
  expect(screen.queryByRole('dialog', { name: 'Post details' })).toBeNull()
132
+ expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
133
+ expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
134
+ expect(screen.queryByRole('button', { name: /Preview page/ })).toBeNull()
129
135
  })
130
136
  })
131
137
 
@@ -201,6 +207,35 @@ describe('PostSectionEditor section disclosure', () => {
201
207
  })
202
208
  })
203
209
 
210
+ describe('PostSectionEditor Share', () => {
211
+ it('saves a dirty draft before opening Share', async () => {
212
+ const post = loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }])
213
+ fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
214
+ fetchPostForEditor.mockResolvedValue(post)
215
+ savePostDraft.mockResolvedValue(post)
216
+
217
+ render(
218
+ <PostSectionEditor
219
+ postType="blog"
220
+ documentId="p1"
221
+ config={{ ...validConfig, seo: { siteUrl: 'https://example.com' } }}
222
+ onNavigate={() => {}}
223
+ session={{}}
224
+ />,
225
+ )
226
+ await screen.findByText('Sections')
227
+
228
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Quote' }))
229
+ fireEvent.change(await screen.findByLabelText('Section name'), {
230
+ target: { value: 'Pull quote' },
231
+ })
232
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }))
233
+
234
+ await waitFor(() => expect(savePostDraft).toHaveBeenCalledOnce())
235
+ expect(await screen.findByRole('dialog', { name: 'Share preview link' })).toBeTruthy()
236
+ })
237
+ })
238
+
204
239
  /**
205
240
  * The post editor was the last screen still on the pre-handoff panel: a fixed
206
241
  * 288px aside with no resize, no collapse and no tabs, with SEO in a
@@ -19,11 +19,12 @@ export interface BreakpointPickerProps {
19
19
  }
20
20
 
21
21
  /**
22
- * Preview-breakpoint selector for the editor header.
22
+ * Canvas-width selector for the editor header.
23
23
  *
24
24
  * A dropdown rather than a segmented row: six presets with names like "Tablet
25
25
  * Landscape" do not fit a header strip, and the grouped menu is what makes the
26
- * DESKTOP / TABLET / MOBILE structure legible.
26
+ * DESKTOP / TABLET / MOBILE structure legible. This only sizes the first-party
27
+ * canvas — it is not a live-site viewport.
27
28
  *
28
29
  * Radix owns the menu behaviour — outside-click, Escape, roving focus, and
29
30
  * `aria-expanded` on the trigger — rather than a hand-rolled popover.
@@ -32,6 +33,7 @@ export function BreakpointPicker({ value, onChange, disabled }: BreakpointPicker
32
33
  const [open, setOpen] = useState(false)
33
34
  const active = getBreakpoint(value)
34
35
  const ActiveIcon = breakpointIcon(active.icon)
36
+ const canvasWidthLabel = `Canvas width: ${active.name}, ${active.width} pixels. First-party layout only — use View site for the live design.`
35
37
 
36
38
  return (
37
39
  <DropdownMenu.Root open={open} onOpenChange={setOpen}>
@@ -39,7 +41,8 @@ export function BreakpointPicker({ value, onChange, disabled }: BreakpointPicker
39
41
  <button
40
42
  type="button"
41
43
  disabled={disabled}
42
- aria-label={`Preview breakpoint: ${active.name}, ${active.width} pixels`}
44
+ aria-label={canvasWidthLabel}
45
+ title={canvasWidthLabel}
43
46
  className={`border-border flex h-[34px] items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-medium transition-colors disabled:opacity-50 ${
44
47
  open
45
48
  ? 'border-brand bg-accent text-accent-foreground'
@@ -27,8 +27,8 @@ export function CanvasEditTip() {
27
27
  <span className="text-foreground font-medium">Editing tip</span>
28
28
  {' — '}
29
29
  Click a section on the canvas, or in the list, to open its copy and images in the section
30
- list. Use <span className="text-foreground font-medium">Site preview</span> to click text
31
- and images in place.
30
+ list. Use <span className="text-foreground font-medium">View site</span> for the live
31
+ design.
32
32
  </p>
33
33
  <button
34
34
  type="button"
@@ -1,70 +1,6 @@
1
- /**
2
- * Shared edit affordances for the page/post canvas and Site preview iframe.
3
- *
4
- * Structure canvas: section hover is styled in `theme.css` under `.prenta-canvas`.
5
- * Site preview: the same field chrome is injected into the iframe document when
6
- * same-origin (CMS-host preview) so consumers get hover borders without shipping CSS.
7
- */
8
-
9
1
  /** localStorage key for the dismissible canvas tip. */
10
2
  export const CANVAS_EDIT_TIP_STORAGE_KEY = 'prenta.canvasEditTip.dismissed'
11
3
 
12
- /**
13
- * CSS injected into the Site preview iframe while editing is enabled.
14
- * Uses system colors so it works without admin theme tokens in the iframe.
15
- */
16
- export const PREVIEW_EDITABLE_FIELD_CHROME_CSS = `
17
- /* Prenta Site preview — editable field hover chrome (injected by @prenta/admin) */
18
- [data-cms-field] {
19
- outline: 1px solid transparent;
20
- outline-offset: 2px;
21
- border-radius: 2px;
22
- transition: outline-color 120ms ease;
23
- cursor: text;
24
- }
25
- [data-cms-field]:hover,
26
- [data-cms-field]:focus,
27
- [data-cms-field]:focus-within {
28
- outline-color: color-mix(in oklab, CanvasText 24%, transparent);
29
- }
30
- [data-cms-field][data-cms-field-kind="media"] {
31
- cursor: pointer;
32
- }
33
- [data-cms-field][data-cms-field-kind="media"]:hover,
34
- [data-cms-field][data-cms-field-kind="media"]:focus {
35
- outline-color: color-mix(in oklab, CanvasText 32%, transparent);
36
- }
37
- @media (prefers-reduced-motion: reduce) {
38
- [data-cms-field] {
39
- transition: none;
40
- }
41
- }
42
- `.trim()
43
-
44
- const STYLE_ATTR = 'data-prenta-edit-chrome'
45
-
46
- /**
47
- * Inject (or refresh) editable-field hover chrome into a same-origin preview
48
- * iframe. No-ops on cross-origin frames (browser blocks contentDocument).
49
- */
50
- export function injectPreviewEditChrome(iframe: HTMLIFrameElement | null): boolean {
51
- try {
52
- const doc = iframe?.contentDocument
53
- if (!doc?.head) return false
54
- let style = doc.head.querySelector<HTMLStyleElement>(`style[${STYLE_ATTR}]`)
55
- if (!style) {
56
- style = doc.createElement('style')
57
- style.setAttribute(STYLE_ATTR, '1')
58
- doc.head.appendChild(style)
59
- }
60
- style.textContent = PREVIEW_EDITABLE_FIELD_CHROME_CSS
61
- doc.documentElement.setAttribute('data-prenta-preview-editing', 'true')
62
- return true
63
- } catch {
64
- return false
65
- }
66
- }
67
-
68
4
  export function isCanvasEditTipDismissed(): boolean {
69
5
  try {
70
6
  return localStorage.getItem(CANVAS_EDIT_TIP_STORAGE_KEY) === '1'
@@ -1,6 +1,9 @@
1
1
  import type { AdminEditorConfig } from '@prenta/core'
2
2
 
3
- /** Resolve whether a section editor should open with Site preview active. */
3
+ /**
4
+ * Ignored by the editors; returns the old boolean for compatibility only.
5
+ * The page and post editors no longer mount an inline site-preview iframe.
6
+ */
4
7
  export function resolveDefaultSitePreview(
5
8
  editor: AdminEditorConfig | undefined,
6
9
  kind: 'pages' | 'posts',
@@ -0,0 +1,57 @@
1
+ import { toast } from 'sonner'
2
+ import { buildPublicPreviewUrl, buildPublicUrl, createPreviewToken } from './preview-link.js'
3
+ import { NO_PUBLIC_SITE_URL_REASON, type ViewSiteIntent } from './view-site-chrome.js'
4
+
5
+ export async function openPublicSiteTab(opts: {
6
+ intent: ViewSiteIntent
7
+ siteUrl: string
8
+ collection: string
9
+ documentId: string
10
+ slug: string
11
+ path?: string | null
12
+ urlPrefix?: string
13
+ data?: Record<string, unknown> | null
14
+ dirty: boolean
15
+ saveDraft: () => Promise<boolean>
16
+ }): Promise<void> {
17
+ const siteUrl = opts.siteUrl.trim()
18
+ if (!siteUrl) throw new Error(NO_PUBLIC_SITE_URL_REASON)
19
+
20
+ const parts = {
21
+ siteUrl,
22
+ urlPrefix: opts.urlPrefix ?? '',
23
+ slug: opts.slug,
24
+ path: opts.path,
25
+ data: opts.data,
26
+ }
27
+
28
+ if (opts.intent === 'view-live') {
29
+ window.open(buildPublicUrl(parts), '_blank', 'noopener')
30
+ return
31
+ }
32
+
33
+ const tab = window.open('about:blank', '_blank')
34
+ if (!tab) {
35
+ toast.error('Enable pop-ups for this site to open the preview in a new tab.')
36
+ return
37
+ }
38
+ try {
39
+ if (opts.dirty) {
40
+ const ok = await opts.saveDraft()
41
+ if (!ok) {
42
+ tab.close()
43
+ return
44
+ }
45
+ }
46
+ const { token } = await createPreviewToken(opts.collection, opts.documentId)
47
+ try {
48
+ tab.opener = null
49
+ } catch {
50
+ /* same-origin guard */
51
+ }
52
+ tab.location.replace(buildPublicPreviewUrl({ ...parts, token }))
53
+ } catch (err) {
54
+ tab.close()
55
+ throw err
56
+ }
57
+ }