@prenta/admin 1.0.0 → 1.1.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 (117) hide show
  1. package/CHANGELOG.md +6 -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/open-public-site-tab.test.d.ts +2 -0
  8. package/dist/__tests__/lib/open-public-site-tab.test.d.ts.map +1 -0
  9. package/dist/__tests__/lib/open-public-site-tab.test.js +64 -0
  10. package/dist/__tests__/lib/open-public-site-tab.test.js.map +1 -0
  11. package/dist/__tests__/lib/view-site-chrome.test.d.ts +2 -0
  12. package/dist/__tests__/lib/view-site-chrome.test.d.ts.map +1 -0
  13. package/dist/__tests__/lib/view-site-chrome.test.js +59 -0
  14. package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -0
  15. package/dist/__tests__/router/admin-routes.test.js +4 -0
  16. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  17. package/dist/__tests__/views/canvas-surface-shared.test.js +0 -16
  18. package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -1
  19. package/dist/__tests__/views/editor-top-bar.render.test.js +44 -22
  20. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  21. package/dist/__tests__/views/page-section-editor.test.js +30 -9
  22. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  23. package/dist/__tests__/views/post-section-editor.test.js +23 -0
  24. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  25. package/dist/components/BreakpointPicker.d.ts +3 -2
  26. package/dist/components/BreakpointPicker.d.ts.map +1 -1
  27. package/dist/components/BreakpointPicker.js +5 -3
  28. package/dist/components/BreakpointPicker.js.map +1 -1
  29. package/dist/components/CanvasEditTip.js +1 -1
  30. package/dist/components/CanvasEditTip.js.map +1 -1
  31. package/dist/lib/editor-preview.d.ts +4 -1
  32. package/dist/lib/editor-preview.d.ts.map +1 -1
  33. package/dist/lib/editor-preview.js +4 -1
  34. package/dist/lib/editor-preview.js.map +1 -1
  35. package/dist/lib/open-public-site-tab.d.ts +14 -0
  36. package/dist/lib/open-public-site-tab.d.ts.map +1 -0
  37. package/dist/lib/open-public-site-tab.js +46 -0
  38. package/dist/lib/open-public-site-tab.js.map +1 -0
  39. package/dist/lib/view-site-chrome.d.ts +16 -0
  40. package/dist/lib/view-site-chrome.d.ts.map +1 -0
  41. package/dist/lib/view-site-chrome.js +15 -0
  42. package/dist/lib/view-site-chrome.js.map +1 -0
  43. package/dist/router/admin-routes.d.ts.map +1 -1
  44. package/dist/router/admin-routes.js +0 -2
  45. package/dist/router/admin-routes.js.map +1 -1
  46. package/dist/views/page-editor/EditorCanvas.d.ts +1 -2
  47. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  48. package/dist/views/page-editor/EditorCanvas.js +2 -2
  49. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  50. package/dist/views/page-editor/EditorTopBar.d.ts +13 -17
  51. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  52. package/dist/views/page-editor/EditorTopBar.js +19 -21
  53. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  54. package/dist/views/page-editor/PageSectionEditor.d.ts +1 -7
  55. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  56. package/dist/views/page-editor/PageSectionEditor.js +29 -95
  57. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  58. package/dist/views/page-editor/StructureNotice.d.ts +2 -5
  59. package/dist/views/page-editor/StructureNotice.d.ts.map +1 -1
  60. package/dist/views/page-editor/StructureNotice.js +6 -10
  61. package/dist/views/page-editor/StructureNotice.js.map +1 -1
  62. package/dist/views/post-editor/PostEditorCanvas.d.ts +1 -3
  63. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  64. package/dist/views/post-editor/PostEditorCanvas.js +3 -3
  65. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  66. package/dist/views/post-editor/PostSectionEditor.d.ts +1 -15
  67. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  68. package/dist/views/post-editor/PostSectionEditor.js +30 -88
  69. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  70. package/package.json +4 -4
  71. package/src/AdminRoot.tsx +0 -70
  72. package/src/__tests__/components/breakpoint-controls.render.test.tsx +6 -4
  73. package/src/__tests__/lib/open-public-site-tab.test.ts +74 -0
  74. package/src/__tests__/lib/view-site-chrome.test.ts +65 -0
  75. package/src/__tests__/router/admin-routes.test.ts +5 -0
  76. package/src/__tests__/views/canvas-surface-shared.test.ts +0 -19
  77. package/src/__tests__/views/editor-top-bar.render.test.tsx +85 -22
  78. package/src/__tests__/views/page-section-editor.test.tsx +42 -9
  79. package/src/__tests__/views/post-section-editor.test.tsx +35 -0
  80. package/src/components/BreakpointPicker.tsx +6 -3
  81. package/src/components/CanvasEditTip.tsx +2 -2
  82. package/src/lib/editor-preview.ts +4 -1
  83. package/src/lib/open-public-site-tab.ts +57 -0
  84. package/src/lib/view-site-chrome.ts +34 -0
  85. package/src/router/admin-routes.ts +0 -2
  86. package/src/styles/theme.css +4 -4
  87. package/src/views/page-editor/EditorCanvas.tsx +1 -6
  88. package/src/views/page-editor/EditorTopBar.tsx +81 -82
  89. package/src/views/page-editor/PageSectionEditor.tsx +77 -157
  90. package/src/views/page-editor/StructureNotice.tsx +7 -42
  91. package/src/views/post-editor/PostEditorCanvas.tsx +2 -8
  92. package/src/views/post-editor/PostSectionEditor.tsx +66 -138
  93. package/dist/__tests__/views/use-site-preview.render.test.d.ts +0 -2
  94. package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +0 -1
  95. package/dist/__tests__/views/use-site-preview.render.test.js +0 -120
  96. package/dist/__tests__/views/use-site-preview.render.test.js.map +0 -1
  97. package/dist/views/page-editor/PagePreview.d.ts +0 -13
  98. package/dist/views/page-editor/PagePreview.d.ts.map +0 -1
  99. package/dist/views/page-editor/PagePreview.js +0 -46
  100. package/dist/views/page-editor/PagePreview.js.map +0 -1
  101. package/dist/views/page-editor/SitePreviewFrame.d.ts +0 -43
  102. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +0 -1
  103. package/dist/views/page-editor/SitePreviewFrame.js +0 -165
  104. package/dist/views/page-editor/SitePreviewFrame.js.map +0 -1
  105. package/dist/views/page-editor/useSitePreview.d.ts +0 -47
  106. package/dist/views/page-editor/useSitePreview.d.ts.map +0 -1
  107. package/dist/views/page-editor/useSitePreview.js +0 -96
  108. package/dist/views/page-editor/useSitePreview.js.map +0 -1
  109. package/dist/views/post-editor/PostPreview.d.ts +0 -14
  110. package/dist/views/post-editor/PostPreview.d.ts.map +0 -1
  111. package/dist/views/post-editor/PostPreview.js +0 -63
  112. package/dist/views/post-editor/PostPreview.js.map +0 -1
  113. package/src/__tests__/views/use-site-preview.render.test.tsx +0 -164
  114. package/src/views/page-editor/PagePreview.tsx +0 -105
  115. package/src/views/page-editor/SitePreviewFrame.tsx +0 -250
  116. package/src/views/page-editor/useSitePreview.ts +0 -158
  117. package/src/views/post-editor/PostPreview.tsx +0 -125
@@ -5,11 +5,13 @@ import { BreakpointPicker } from '../../components/BreakpointPicker.js'
5
5
  import { ZoomChip } from '../../components/ZoomChip.js'
6
6
 
7
7
  describe('BreakpointPicker', () => {
8
- it('names the current breakpoint and its width to assistive tech', () => {
8
+ it('names canvas width, not a live preview', () => {
9
9
  render(<BreakpointPicker value="tabletL" onChange={vi.fn()} />)
10
- // The visible label collapses to an abbreviation at narrow header widths,
11
- // so the accessible name has to carry the full fact on its own.
12
- expect(screen.getByRole('button', { name: /Tablet Landscape, 1024 pixels/i })).toBeTruthy()
10
+ expect(
11
+ screen.getByRole('button', {
12
+ name: 'Canvas width: Tablet Landscape, 1024 pixels. First-party layout only — use View site for the live design.',
13
+ }),
14
+ ).toBeTruthy()
13
15
  })
14
16
 
15
17
  it('renders 4K at 2560, not the physical pixel count', () => {
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const createPreviewToken = vi.fn()
4
+ vi.mock('../../lib/preview-link.js', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('../../lib/preview-link.js')>()
6
+ return { ...actual, createPreviewToken }
7
+ })
8
+
9
+ const { openPublicSiteTab } = await import('../../lib/open-public-site-tab.js')
10
+ const { NO_PUBLIC_SITE_URL_REASON } = await import('../../lib/view-site-chrome.js')
11
+
12
+ function mockTab() {
13
+ return { close: vi.fn(), location: { replace: vi.fn() }, opener: {} as unknown }
14
+ }
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals()
18
+ createPreviewToken.mockReset()
19
+ })
20
+
21
+ const base = {
22
+ siteUrl: 'https://example.com',
23
+ collection: 'pages',
24
+ documentId: 'p1',
25
+ slug: 'about',
26
+ path: '/about',
27
+ dirty: false,
28
+ saveDraft: vi.fn().mockResolvedValue(true),
29
+ }
30
+
31
+ describe('openPublicSiteTab', () => {
32
+ it('refuses to open the admin host when siteUrl is empty', async () => {
33
+ const open = vi.fn()
34
+ vi.stubGlobal('window', { open })
35
+ await expect(openPublicSiteTab({ ...base, siteUrl: '', intent: 'view-live' })).rejects.toThrow(
36
+ NO_PUBLIC_SITE_URL_REASON,
37
+ )
38
+ expect(open).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('opens the live URL with no token', async () => {
42
+ const open = vi.fn()
43
+ vi.stubGlobal('window', { open })
44
+ await openPublicSiteTab({ ...base, intent: 'view-live' })
45
+ expect(open).toHaveBeenCalledWith('https://example.com/about', '_blank', 'noopener')
46
+ expect(createPreviewToken).not.toHaveBeenCalled()
47
+ expect(base.saveDraft).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('saves a dirty draft, mints a token, and replaces about:blank', async () => {
51
+ const tab = mockTab()
52
+ const open = vi.fn().mockReturnValue(tab)
53
+ vi.stubGlobal('window', { open })
54
+ createPreviewToken.mockResolvedValue({ token: 'tok', expiresAt: 'x' })
55
+ await openPublicSiteTab({ ...base, intent: 'preview-draft', dirty: true })
56
+ expect(open).toHaveBeenCalledWith('about:blank', '_blank')
57
+ expect(base.saveDraft).toHaveBeenCalledOnce()
58
+ expect(createPreviewToken).toHaveBeenCalledWith('pages', 'p1')
59
+ expect(tab.location.replace).toHaveBeenCalledWith('https://example.com/about?preview=tok')
60
+ })
61
+
62
+ it('closes the blank tab when save fails', async () => {
63
+ const tab = mockTab()
64
+ vi.stubGlobal('window', { open: vi.fn().mockReturnValue(tab) })
65
+ await openPublicSiteTab({
66
+ ...base,
67
+ intent: 'preview-draft',
68
+ dirty: true,
69
+ saveDraft: vi.fn().mockResolvedValue(false),
70
+ })
71
+ expect(tab.close).toHaveBeenCalledOnce()
72
+ expect(createPreviewToken).not.toHaveBeenCalled()
73
+ })
74
+ })
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { NO_PUBLIC_SITE_URL_REASON, resolveViewSiteChrome } from '../../lib/view-site-chrome.js'
3
+
4
+ const base = {
5
+ dirty: false,
6
+ hasPublicSiteUrl: true,
7
+ hasIdentity: true,
8
+ }
9
+
10
+ describe('resolveViewSiteChrome', () => {
11
+ it('previews a draft and shows Share', () => {
12
+ expect(resolveViewSiteChrome({ ...base, status: 'DRAFT' })).toEqual({
13
+ viewEnabled: true,
14
+ primaryIntent: 'preview-draft',
15
+ showViewLive: false,
16
+ shareVisible: true,
17
+ })
18
+ })
19
+
20
+ it('treats scheduled and archived like a draft', () => {
21
+ for (const status of ['SCHEDULED', 'ARCHIVED'] as const) {
22
+ expect(resolveViewSiteChrome({ ...base, status }).primaryIntent).toBe('preview-draft')
23
+ expect(resolveViewSiteChrome({ ...base, status }).shareVisible).toBe(true)
24
+ }
25
+ })
26
+
27
+ it('opens live on a clean published document and hides Share', () => {
28
+ expect(resolveViewSiteChrome({ ...base, status: 'PUBLISHED' })).toEqual({
29
+ viewEnabled: true,
30
+ primaryIntent: 'view-live',
31
+ showViewLive: false,
32
+ shareVisible: false,
33
+ })
34
+ })
35
+
36
+ it('previews unpublished changes when published and dirty, and keeps View live + Share', () => {
37
+ expect(resolveViewSiteChrome({ ...base, status: 'PUBLISHED', dirty: true })).toEqual({
38
+ viewEnabled: true,
39
+ primaryIntent: 'preview-draft',
40
+ showViewLive: true,
41
+ shareVisible: true,
42
+ })
43
+ })
44
+
45
+ it('disables View site and Share when the public site URL is missing', () => {
46
+ const chrome = resolveViewSiteChrome({
47
+ ...base,
48
+ status: 'DRAFT',
49
+ hasPublicSiteUrl: false,
50
+ })
51
+ expect(chrome.viewEnabled).toBe(false)
52
+ expect(chrome.viewDisabledReason).toBe(NO_PUBLIC_SITE_URL_REASON)
53
+ expect(chrome.shareVisible).toBe(true)
54
+ })
55
+
56
+ it('hides View site and Share when the document is unsaved', () => {
57
+ const chrome = resolveViewSiteChrome({
58
+ ...base,
59
+ status: 'DRAFT',
60
+ hasIdentity: false,
61
+ })
62
+ expect(chrome.viewEnabled).toBe(false)
63
+ expect(chrome.shareVisible).toBe(false)
64
+ })
65
+ })
@@ -71,4 +71,9 @@ describe('ADMIN_STATIC_ROUTE_PATTERNS stays in sync with AdminRoot', () => {
71
71
  const missing = [...new Set(literals)].filter((p) => !known.has(p))
72
72
  expect(missing).toEqual([])
73
73
  })
74
+
75
+ it('does not keep the retired admin-renderer preview routes', () => {
76
+ expect(ADMIN_STATIC_ROUTE_PATTERNS).not.toContain('/pages/:id/preview')
77
+ expect(ADMIN_STATIC_ROUTE_PATTERNS).not.toContain('/posts/:type/:id/preview')
78
+ })
74
79
  })
@@ -58,23 +58,4 @@ describe('canvas surface is shared, not copied', () => {
58
58
  expect(read(rel), `${rel} re-implements the scale`).not.toMatch(/fitScale/)
59
59
  }
60
60
  })
61
-
62
- /**
63
- * `SitePreviewFrame` was the third copy of `clientWidth - 48` and the one
64
- * followup 2 left alone, because it scales an IFRAME rather than a rendered
65
- * surface and cannot render `CanvasSurface`. It can still measure the same
66
- * way, and now does: the padding arithmetic is gone even though the
67
- * component is not shared.
68
- *
69
- * Kept apart from CANVASES on purpose — the assertion here is about the
70
- * measurement, not about rendering the shared surface, and folding it in
71
- * would make the other two tests claim something untrue about it.
72
- */
73
- it('the site preview measures from a sentinel too, though it cannot share the surface', () => {
74
- const preview = read('src/views/page-editor/SitePreviewFrame.tsx')
75
- expect(preview, 'SitePreviewFrame still does padding arithmetic').not.toMatch(
76
- /clientWidth\s*-\s*\d/,
77
- )
78
- expect(preview, 'SitePreviewFrame does not measure from a sentinel').toMatch(/sentinelRef/)
79
- })
80
61
  })
@@ -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,93 @@ 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 public site URL is missing', () => {
155
+ render(
156
+ <EditorTopBar
157
+ {...baseProps}
158
+ onViewOnSite={vi.fn()}
159
+ onShare={vi.fn()}
160
+ hasPublicSiteUrl={false}
161
+ />,
162
+ )
163
+ const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
164
+ const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
165
+ expect(view.disabled).toBe(true)
166
+ expect(share.disabled).toBe(true)
167
+ expect(view.getAttribute('title')).toBe(
168
+ 'Set the public site URL in Settings → SEO to open the live design.',
169
+ )
105
170
  })
106
171
 
107
- it('hides the button when onViewOnSite is omitted (unsaved doc)', () => {
172
+ it('hides View site and Share when onViewOnSite is omitted (unsaved doc)', () => {
108
173
  render(<EditorTopBar {...baseProps} status="PUBLISHED" />)
109
- expect(screen.queryByRole('button', { name: /View (live|draft)/ })).toBeNull()
174
+ expect(screen.queryByRole('button', { name: 'View site' })).toBeNull()
175
+ expect(screen.queryByRole('button', { name: 'View live' })).toBeNull()
176
+ expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
110
177
  })
111
178
  })
112
179
 
@@ -133,19 +200,15 @@ describe('EditorTopBar history button', () => {
133
200
  render(
134
201
  <EditorTopBar
135
202
  {...baseProps}
203
+ status="PUBLISHED"
204
+ dirty
205
+ hasPublicSiteUrl
136
206
  onHistory={vi.fn()}
137
207
  onViewOnSite={vi.fn()}
138
- onToggleSitePreview={vi.fn()}
139
208
  />,
140
209
  )
141
210
 
142
- for (const name of [
143
- 'Page settings',
144
- 'Version history',
145
- 'Preview page',
146
- 'Inline site preview',
147
- 'View draft on site',
148
- ]) {
211
+ for (const name of ['Page settings', 'Version history', 'More view options']) {
149
212
  const button = screen.getByRole('button', { name })
150
213
  expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
151
214
  }
@@ -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 () => {
@@ -233,3 +239,30 @@ describe('PageSectionEditor section disclosure', () => {
233
239
  expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
234
240
  })
235
241
  })
242
+
243
+ describe('PageSectionEditor Share', () => {
244
+ const configWithSite = { ...validConfig, seo: { siteUrl: 'https://example.com' } }
245
+
246
+ it('saves a dirty draft before opening Share', async () => {
247
+ const page = loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }])
248
+ fetchPageForEditor.mockResolvedValue(page)
249
+ savePageDraft.mockResolvedValue(page)
250
+
251
+ render(
252
+ <PageSectionEditor
253
+ documentId="p1"
254
+ config={configWithSite}
255
+ onNavigate={() => {}}
256
+ session={{}}
257
+ />,
258
+ )
259
+ await screen.findByText('Sections')
260
+
261
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
262
+ fireEvent.change(await screen.findByLabelText('Section name'), { target: { value: 'Intro' } })
263
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }))
264
+
265
+ await waitFor(() => expect(savePageDraft).toHaveBeenCalledOnce())
266
+ expect(await screen.findByRole('dialog', { name: 'Share preview link' })).toBeTruthy()
267
+ })
268
+ })
@@ -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,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
+ }
@@ -0,0 +1,34 @@
1
+ import type { PageStatus } from './page-editor-service.js'
2
+
3
+ export type ViewSiteIntent = 'preview-draft' | 'view-live'
4
+
5
+ export const NO_PUBLIC_SITE_URL_REASON =
6
+ 'Set the public site URL in Settings → SEO to open the live design.'
7
+
8
+ export function resolveViewSiteChrome(input: {
9
+ status: PageStatus
10
+ dirty: boolean
11
+ hasPublicSiteUrl: boolean
12
+ hasIdentity: boolean
13
+ }): {
14
+ viewEnabled: boolean
15
+ viewDisabledReason?: string
16
+ primaryIntent: ViewSiteIntent
17
+ showViewLive: boolean
18
+ shareVisible: boolean
19
+ } {
20
+ const unpublished = input.status !== 'PUBLISHED' || input.dirty
21
+ const primaryIntent: ViewSiteIntent =
22
+ input.status === 'PUBLISHED' && !input.dirty ? 'view-live' : 'preview-draft'
23
+ const shareVisible = input.hasIdentity && unpublished
24
+ const viewEnabled = input.hasIdentity && input.hasPublicSiteUrl
25
+
26
+ return {
27
+ viewEnabled,
28
+ viewDisabledReason:
29
+ input.hasIdentity && !input.hasPublicSiteUrl ? NO_PUBLIC_SITE_URL_REASON : undefined,
30
+ primaryIntent,
31
+ showViewLive: input.hasIdentity && input.status === 'PUBLISHED' && input.dirty,
32
+ shareVisible,
33
+ }
34
+ }
@@ -40,14 +40,12 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
40
40
  '/pages',
41
41
  '/pages/:id',
42
42
  '/pages/:id/edit',
43
- '/pages/:id/preview',
44
43
  '/pages/new',
45
44
  '/pages/tags',
46
45
  '/pages/tags/:id/edit',
47
46
  '/pages/tags/new',
48
47
  '/posts',
49
48
  '/posts/:type/:id/edit',
50
- '/posts/:type/:id/preview',
51
49
  '/posts/:type/new',
52
50
  '/posts/new',
53
51
  '/posts/types',