@actuate-media/cms-admin 0.47.0 → 0.49.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.
- package/CHANGELOG.md +18 -0
- package/dist/AdminRoot.d.ts +10 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +15 -32
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts +54 -0
- package/dist/LoginRoot.d.ts.map +1 -0
- package/dist/LoginRoot.js +59 -0
- package/dist/LoginRoot.js.map +1 -0
- package/dist/__tests__/views/login-root.render.test.d.ts +2 -0
- package/dist/__tests__/views/login-root.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/login-root.render.test.js +79 -0
- package/dist/__tests__/views/login-root.render.test.js.map +1 -0
- package/dist/__tests__/views/use-site-preview.render.test.d.ts +2 -0
- package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-site-preview.render.test.js +86 -0
- package/dist/__tests__/views/use-site-preview.render.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/root-chrome.d.ts +15 -0
- package/dist/lib/root-chrome.d.ts.map +1 -0
- package/dist/lib/root-chrome.js +41 -0
- package/dist/lib/root-chrome.js.map +1 -0
- package/dist/views/HiddenLoginNotFound.d.ts +8 -0
- package/dist/views/HiddenLoginNotFound.d.ts.map +1 -0
- package/dist/views/HiddenLoginNotFound.js +29 -0
- package/dist/views/HiddenLoginNotFound.js.map +1 -0
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +7 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +14 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SitePreviewFrame.d.ts +18 -0
- package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -0
- package/dist/views/page-editor/SitePreviewFrame.js +45 -0
- package/dist/views/page-editor/SitePreviewFrame.js.map +1 -0
- package/dist/views/page-editor/useSitePreview.d.ts +33 -0
- package/dist/views/page-editor/useSitePreview.d.ts.map +1 -0
- package/dist/views/page-editor/useSitePreview.js +72 -0
- package/dist/views/page-editor/useSitePreview.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +14 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +26 -29
- package/src/LoginRoot.tsx +166 -0
- package/src/__tests__/views/login-root.render.test.tsx +115 -0
- package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
- package/src/index.ts +2 -0
- package/src/lib/root-chrome.ts +40 -0
- package/src/views/HiddenLoginNotFound.tsx +45 -0
- package/src/views/page-editor/EditorTopBar.tsx +31 -0
- package/src/views/page-editor/PageSectionEditor.tsx +33 -8
- package/src/views/page-editor/SitePreviewFrame.tsx +85 -0
- package/src/views/page-editor/useSitePreview.ts +117 -0
- package/src/views/post-editor/PostSectionEditor.tsx +35 -10
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Full-viewport overlay style shared by the admin and login mounts so the CMS
|
|
6
|
+
* UI renders above any consumer page chrome, with its own stacking context.
|
|
7
|
+
*/
|
|
8
|
+
export const ISOLATION_STYLE: React.CSSProperties = {
|
|
9
|
+
position: 'fixed',
|
|
10
|
+
inset: '0',
|
|
11
|
+
zIndex: 50,
|
|
12
|
+
overflow: 'auto',
|
|
13
|
+
isolation: 'isolate',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The admin (and its login screens) are private application UI and must never
|
|
18
|
+
* be indexed. Ensure a `noindex, nofollow` robots meta tag is present while
|
|
19
|
+
* mounted, as defense-in-depth alongside the server `robots.txt` Disallow
|
|
20
|
+
* rule. This covers every consumer install (the admin page is typically a
|
|
21
|
+
* client component that can't export Next.js route metadata).
|
|
22
|
+
*/
|
|
23
|
+
export function useRobotsNoIndex(): void {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (typeof document === 'undefined') return
|
|
26
|
+
const prev = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
|
|
27
|
+
const prevContent = prev?.getAttribute('content') ?? null
|
|
28
|
+
const meta = prev ?? document.createElement('meta')
|
|
29
|
+
meta.setAttribute('name', 'robots')
|
|
30
|
+
meta.setAttribute('content', 'noindex, nofollow')
|
|
31
|
+
if (!prev) document.head.appendChild(meta)
|
|
32
|
+
return () => {
|
|
33
|
+
if (prevContent === null) {
|
|
34
|
+
meta.remove()
|
|
35
|
+
} else {
|
|
36
|
+
meta.setAttribute('content', prevContent)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, [])
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rendered at the admin mount for signed-out visitors on split-login installs
|
|
5
|
+
* (`config.admin.loginPath` set). Mimics the framework's default 404 page so
|
|
6
|
+
* hitting `/admin` reveals nothing — no login form, no admin branding, and no
|
|
7
|
+
* redirect that would leak the real login location.
|
|
8
|
+
*/
|
|
9
|
+
export function HiddenLoginNotFound() {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
style={{
|
|
13
|
+
fontFamily: 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
14
|
+
height: '100vh',
|
|
15
|
+
display: 'flex',
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
justifyContent: 'center',
|
|
18
|
+
background: '#fff',
|
|
19
|
+
color: '#000',
|
|
20
|
+
textAlign: 'center',
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<div style={{ display: 'flex', alignItems: 'center', lineHeight: '49px' }}>
|
|
24
|
+
<h1
|
|
25
|
+
style={{
|
|
26
|
+
display: 'inline-block',
|
|
27
|
+
margin: '0 20px 0 0',
|
|
28
|
+
paddingRight: 23,
|
|
29
|
+
fontSize: 24,
|
|
30
|
+
fontWeight: 500,
|
|
31
|
+
verticalAlign: 'top',
|
|
32
|
+
borderRight: '1px solid rgba(0, 0, 0, .3)',
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
404
|
|
36
|
+
</h1>
|
|
37
|
+
<div style={{ display: 'inline-block' }}>
|
|
38
|
+
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 400, lineHeight: '49px' }}>
|
|
39
|
+
This page could not be found.
|
|
40
|
+
</h2>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Check,
|
|
5
5
|
ExternalLink,
|
|
6
6
|
Eye,
|
|
7
|
+
Globe,
|
|
7
8
|
History,
|
|
8
9
|
Laptop,
|
|
9
10
|
Loader2,
|
|
@@ -44,6 +45,13 @@ interface EditorTopBarProps {
|
|
|
44
45
|
* hidden — e.g. for unsaved documents that have no versions yet.
|
|
45
46
|
*/
|
|
46
47
|
onHistory?: () => void
|
|
48
|
+
/** Whether the inline site preview (iframe of the public site) is showing. */
|
|
49
|
+
sitePreview?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Toggle the inline site preview — renders the draft through the consumer's
|
|
52
|
+
* real public route inside the editor. Hidden when omitted (unsaved docs).
|
|
53
|
+
*/
|
|
54
|
+
onToggleSitePreview?: () => void
|
|
47
55
|
onOpenSettings: () => void
|
|
48
56
|
onSaveDraft: () => void
|
|
49
57
|
onPublish: () => void
|
|
@@ -85,6 +93,8 @@ export function EditorTopBar({
|
|
|
85
93
|
onPreview,
|
|
86
94
|
onViewOnSite,
|
|
87
95
|
onHistory,
|
|
96
|
+
sitePreview = false,
|
|
97
|
+
onToggleSitePreview,
|
|
88
98
|
onOpenSettings,
|
|
89
99
|
onSaveDraft,
|
|
90
100
|
onPublish,
|
|
@@ -187,6 +197,27 @@ export function EditorTopBar({
|
|
|
187
197
|
<Eye className="h-4 w-4" aria-hidden />
|
|
188
198
|
</button>
|
|
189
199
|
|
|
200
|
+
{onToggleSitePreview && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onToggleSitePreview}
|
|
204
|
+
aria-pressed={sitePreview}
|
|
205
|
+
aria-label={sitePreview ? 'Back to editor canvas' : 'Inline site preview'}
|
|
206
|
+
title={
|
|
207
|
+
sitePreview
|
|
208
|
+
? 'Back to editor canvas'
|
|
209
|
+
: 'Inline site preview — render the draft through your public site, in place'
|
|
210
|
+
}
|
|
211
|
+
className={`rounded-lg p-2 transition-colors ${
|
|
212
|
+
sitePreview
|
|
213
|
+
? 'bg-primary text-primary-foreground'
|
|
214
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
215
|
+
}`}
|
|
216
|
+
>
|
|
217
|
+
<Globe className="h-4 w-4" aria-hidden />
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
|
|
190
221
|
{onViewOnSite &&
|
|
191
222
|
(structural ? (
|
|
192
223
|
<button
|
|
@@ -37,6 +37,8 @@ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js
|
|
|
37
37
|
import { EditorTopBar, type SaveState } from './EditorTopBar.js'
|
|
38
38
|
import { SectionsPanel } from './SectionsPanel.js'
|
|
39
39
|
import { EditorCanvas } from './EditorCanvas.js'
|
|
40
|
+
import { SitePreviewFrame } from './SitePreviewFrame.js'
|
|
41
|
+
import { useSitePreview } from './useSitePreview.js'
|
|
40
42
|
import { SectionInspector } from './SectionInspector.js'
|
|
41
43
|
import { AddSectionModal } from './AddSectionModal.js'
|
|
42
44
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
@@ -263,6 +265,18 @@ export function PageSectionEditor({
|
|
|
263
265
|
}
|
|
264
266
|
}, [page, documentId, onNavigate])
|
|
265
267
|
|
|
268
|
+
// Inline branded preview — iframe of the public site rendering this draft.
|
|
269
|
+
const sitePreview = useSitePreview({
|
|
270
|
+
collection: 'pages',
|
|
271
|
+
documentId: page?.id,
|
|
272
|
+
slug: page?.slug,
|
|
273
|
+
urlPrefix: '',
|
|
274
|
+
siteUrl: config?.seo?.siteUrl,
|
|
275
|
+
dirty,
|
|
276
|
+
saveState,
|
|
277
|
+
saveDraft: handleSaveDraft,
|
|
278
|
+
})
|
|
279
|
+
|
|
266
280
|
const requestPublish = useCallback(() => {
|
|
267
281
|
if (!page) return
|
|
268
282
|
const result = validatePage(page, true)
|
|
@@ -450,6 +464,8 @@ export function PageSectionEditor({
|
|
|
450
464
|
onBack={() => guardedNavigate('/pages')}
|
|
451
465
|
onPreview={handlePreview}
|
|
452
466
|
onViewOnSite={page.id ? handleViewOnSite : undefined}
|
|
467
|
+
sitePreview={sitePreview.active}
|
|
468
|
+
onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
|
|
453
469
|
onHistory={page.id ? () => setHistoryOpen(true) : undefined}
|
|
454
470
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
455
471
|
onSaveDraft={handleSaveDraft}
|
|
@@ -519,14 +535,23 @@ export function PageSectionEditor({
|
|
|
519
535
|
/>
|
|
520
536
|
|
|
521
537
|
<div className="flex-1 overflow-hidden">
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
538
|
+
{sitePreview.active ? (
|
|
539
|
+
<SitePreviewFrame
|
|
540
|
+
src={sitePreview.url}
|
|
541
|
+
viewport={viewport}
|
|
542
|
+
reloadKey={sitePreview.reloadKey}
|
|
543
|
+
title={`Site preview: ${page.title || 'Untitled page'}`}
|
|
544
|
+
/>
|
|
545
|
+
) : (
|
|
546
|
+
<EditorCanvas
|
|
547
|
+
sections={page.sections}
|
|
548
|
+
selectedId={selectedId}
|
|
549
|
+
viewport={viewport}
|
|
550
|
+
onSelect={setSelectedId}
|
|
551
|
+
onAddSection={() => setAddOpen(true)}
|
|
552
|
+
structuralTypes={structuralTypes}
|
|
553
|
+
/>
|
|
554
|
+
)}
|
|
530
555
|
</div>
|
|
531
556
|
|
|
532
557
|
{selected && (
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
4
|
+
import { Loader2 } from 'lucide-react'
|
|
5
|
+
import { getViewport, type ViewportId } from './viewports.js'
|
|
6
|
+
|
|
7
|
+
interface SitePreviewFrameProps {
|
|
8
|
+
/** Public draft URL; null while the preview token is still minting. */
|
|
9
|
+
src: string | null
|
|
10
|
+
viewport: ViewportId
|
|
11
|
+
/** Changing this remounts the iframe so it reloads the persisted draft. */
|
|
12
|
+
reloadKey: number
|
|
13
|
+
title?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Inline branded preview surface — replaces the editor canvas with an iframe
|
|
18
|
+
* of the consumer's real public route rendering the draft (site CSS, real
|
|
19
|
+
* components). Sized to the selected viewport's logical width and scaled to
|
|
20
|
+
* fit the canvas column, mirroring {@link EditorCanvas}.
|
|
21
|
+
*/
|
|
22
|
+
export function SitePreviewFrame({
|
|
23
|
+
src,
|
|
24
|
+
viewport,
|
|
25
|
+
reloadKey,
|
|
26
|
+
title = 'Site preview',
|
|
27
|
+
}: SitePreviewFrameProps) {
|
|
28
|
+
const vp = getViewport(viewport)
|
|
29
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
const [scale, setScale] = useState(1)
|
|
31
|
+
const [frameHeight, setFrameHeight] = useState(600)
|
|
32
|
+
const [loaded, setLoaded] = useState(false)
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const container = containerRef.current
|
|
36
|
+
if (!container) return
|
|
37
|
+
const recompute = () => {
|
|
38
|
+
// 48px breathing room, matching EditorCanvas.
|
|
39
|
+
const availableW = container.clientWidth - 48
|
|
40
|
+
const availableH = container.clientHeight - 48
|
|
41
|
+
const next = Math.min(1, availableW / vp.width)
|
|
42
|
+
setScale(next > 0 ? next : 1)
|
|
43
|
+
setFrameHeight(availableH > 0 ? availableH : 600)
|
|
44
|
+
}
|
|
45
|
+
recompute()
|
|
46
|
+
const ro = new ResizeObserver(recompute)
|
|
47
|
+
ro.observe(container)
|
|
48
|
+
return () => ro.disconnect()
|
|
49
|
+
}, [vp.width])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setLoaded(false)
|
|
53
|
+
}, [src, reloadKey])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div ref={containerRef} className="bg-muted relative h-full overflow-hidden p-6">
|
|
57
|
+
{(!src || !loaded) && (
|
|
58
|
+
<div
|
|
59
|
+
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2"
|
|
60
|
+
aria-live="polite"
|
|
61
|
+
>
|
|
62
|
+
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
|
|
63
|
+
<span className="text-muted-foreground text-sm">Loading site preview…</span>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
{src && (
|
|
67
|
+
<div className="mx-auto" style={{ width: vp.width * scale, height: frameHeight }}>
|
|
68
|
+
<iframe
|
|
69
|
+
key={reloadKey}
|
|
70
|
+
src={src}
|
|
71
|
+
title={title}
|
|
72
|
+
onLoad={() => setLoaded(true)}
|
|
73
|
+
className="border-border rounded-xl border bg-white shadow-sm"
|
|
74
|
+
style={{
|
|
75
|
+
width: vp.width,
|
|
76
|
+
height: frameHeight / scale,
|
|
77
|
+
transform: `scale(${scale})`,
|
|
78
|
+
transformOrigin: 'top left',
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import {
|
|
6
|
+
buildPublicPreviewUrl,
|
|
7
|
+
createPreviewToken,
|
|
8
|
+
resolveSiteUrl,
|
|
9
|
+
} from '../../lib/preview-link.js'
|
|
10
|
+
import type { SaveState } from './EditorTopBar.js'
|
|
11
|
+
|
|
12
|
+
interface UseSitePreviewOptions {
|
|
13
|
+
collection: string
|
|
14
|
+
documentId?: string | null
|
|
15
|
+
slug?: string | null
|
|
16
|
+
/** Collection URL prefix (e.g. `blog`). Empty/null for top-level pages. */
|
|
17
|
+
urlPrefix?: string | null
|
|
18
|
+
/** Raw `config.seo.siteUrl`; falls back to the current origin. */
|
|
19
|
+
siteUrl?: string | null
|
|
20
|
+
dirty: boolean
|
|
21
|
+
saveState: SaveState
|
|
22
|
+
/** Persist the draft; resolves `false` when the save failed. */
|
|
23
|
+
saveDraft: () => Promise<boolean>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SitePreviewState {
|
|
27
|
+
/** Whether the inline iframe preview replaces the editor canvas. */
|
|
28
|
+
active: boolean
|
|
29
|
+
/** Public draft URL (`/<slug>?preview=<jwt>`); null while the token mints. */
|
|
30
|
+
url: string | null
|
|
31
|
+
/** Bumped after each completed save so the iframe reloads the fresh draft. */
|
|
32
|
+
reloadKey: number
|
|
33
|
+
toggle: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Inline branded preview — drives an in-editor iframe that renders the draft
|
|
38
|
+
* through the consumer's REAL public route (same `?preview=<token>` contract
|
|
39
|
+
* as "View on site"), so editors see actual site CSS without leaving the
|
|
40
|
+
* admin. Saves first when dirty, and reloads the frame after each save so
|
|
41
|
+
* what you see always matches the persisted draft.
|
|
42
|
+
*/
|
|
43
|
+
export function useSitePreview({
|
|
44
|
+
collection,
|
|
45
|
+
documentId,
|
|
46
|
+
slug,
|
|
47
|
+
urlPrefix,
|
|
48
|
+
siteUrl,
|
|
49
|
+
dirty,
|
|
50
|
+
saveState,
|
|
51
|
+
saveDraft,
|
|
52
|
+
}: UseSitePreviewOptions): SitePreviewState {
|
|
53
|
+
const [active, setActive] = useState(false)
|
|
54
|
+
const [url, setUrl] = useState<string | null>(null)
|
|
55
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
56
|
+
const tokenRef = useRef<string | null>(null)
|
|
57
|
+
const prevSaveState = useRef(saveState)
|
|
58
|
+
|
|
59
|
+
// After a save completes while the preview is open, rebuild the URL (the
|
|
60
|
+
// slug may have changed in settings — the token is bound to the document
|
|
61
|
+
// id, not the path) and reload the frame.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (active && prevSaveState.current === 'saving' && saveState === 'saved') {
|
|
64
|
+
if (tokenRef.current && slug) {
|
|
65
|
+
setUrl(
|
|
66
|
+
buildPublicPreviewUrl({
|
|
67
|
+
siteUrl: resolveSiteUrl(siteUrl),
|
|
68
|
+
urlPrefix: urlPrefix ?? '',
|
|
69
|
+
slug,
|
|
70
|
+
token: tokenRef.current,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
setReloadKey((k) => k + 1)
|
|
75
|
+
}
|
|
76
|
+
prevSaveState.current = saveState
|
|
77
|
+
}, [active, saveState, slug, siteUrl, urlPrefix])
|
|
78
|
+
|
|
79
|
+
const toggle = useCallback(() => {
|
|
80
|
+
if (active) {
|
|
81
|
+
setActive(false)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (!documentId || !slug) {
|
|
85
|
+
toast.error('Save the page before opening the site preview')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
setActive(true)
|
|
89
|
+
setUrl(null)
|
|
90
|
+
void (async () => {
|
|
91
|
+
try {
|
|
92
|
+
if (dirty) {
|
|
93
|
+
const ok = await saveDraft()
|
|
94
|
+
if (!ok) {
|
|
95
|
+
setActive(false)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { token } = await createPreviewToken(collection, documentId)
|
|
100
|
+
tokenRef.current = token
|
|
101
|
+
setUrl(
|
|
102
|
+
buildPublicPreviewUrl({
|
|
103
|
+
siteUrl: resolveSiteUrl(siteUrl),
|
|
104
|
+
urlPrefix: urlPrefix ?? '',
|
|
105
|
+
slug,
|
|
106
|
+
token,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
toast.error(err instanceof Error ? err.message : 'Failed to open site preview')
|
|
111
|
+
setActive(false)
|
|
112
|
+
}
|
|
113
|
+
})()
|
|
114
|
+
}, [active, collection, documentId, slug, urlPrefix, siteUrl, dirty, saveDraft])
|
|
115
|
+
|
|
116
|
+
return { active, url, reloadKey, toggle }
|
|
117
|
+
}
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
} from '../page-editor/sections/index.js'
|
|
43
43
|
import { DEFAULT_VIEWPORT, type ViewportId } from '../page-editor/viewports.js'
|
|
44
44
|
import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
|
|
45
|
+
import { SitePreviewFrame } from '../page-editor/SitePreviewFrame.js'
|
|
46
|
+
import { useSitePreview } from '../page-editor/useSitePreview.js'
|
|
45
47
|
import { SectionsPanel } from '../page-editor/SectionsPanel.js'
|
|
46
48
|
import { SectionInspector } from '../page-editor/SectionInspector.js'
|
|
47
49
|
import { AddSectionModal } from '../page-editor/AddSectionModal.js'
|
|
@@ -312,6 +314,18 @@ export function PostSectionEditor({
|
|
|
312
314
|
}
|
|
313
315
|
}, [post, documentId, postType, onNavigate])
|
|
314
316
|
|
|
317
|
+
// Inline branded preview — iframe of the public site rendering this draft.
|
|
318
|
+
const sitePreview = useSitePreview({
|
|
319
|
+
collection: postType,
|
|
320
|
+
documentId: post?.id,
|
|
321
|
+
slug: post?.slug,
|
|
322
|
+
urlPrefix: config?.collections?.[postType]?.urlPrefix ?? postType,
|
|
323
|
+
siteUrl: config?.seo?.siteUrl,
|
|
324
|
+
dirty,
|
|
325
|
+
saveState,
|
|
326
|
+
saveDraft: handleSaveDraft,
|
|
327
|
+
})
|
|
328
|
+
|
|
315
329
|
const requestPublish = useCallback(() => {
|
|
316
330
|
if (!post) return
|
|
317
331
|
const result = validatePost(post, true)
|
|
@@ -491,6 +505,8 @@ export function PostSectionEditor({
|
|
|
491
505
|
onBack={() => guardedNavigate('/posts')}
|
|
492
506
|
onPreview={handlePreview}
|
|
493
507
|
onViewOnSite={post.id ? handleViewOnSite : undefined}
|
|
508
|
+
sitePreview={sitePreview.active}
|
|
509
|
+
onToggleSitePreview={post.id && post.slug ? sitePreview.toggle : undefined}
|
|
494
510
|
onHistory={post.id ? () => setHistoryOpen(true) : undefined}
|
|
495
511
|
onOpenSettings={() => setFieldsOpen(true)}
|
|
496
512
|
onSaveDraft={handleSaveDraft}
|
|
@@ -537,16 +553,25 @@ export function PostSectionEditor({
|
|
|
537
553
|
/>
|
|
538
554
|
|
|
539
555
|
<div className="flex-1 overflow-hidden">
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
556
|
+
{sitePreview.active ? (
|
|
557
|
+
<SitePreviewFrame
|
|
558
|
+
src={sitePreview.url}
|
|
559
|
+
viewport={viewport}
|
|
560
|
+
reloadKey={sitePreview.reloadKey}
|
|
561
|
+
title={`Site preview: ${post.title || `New ${typeLabel}`}`}
|
|
562
|
+
/>
|
|
563
|
+
) : (
|
|
564
|
+
<PostEditorCanvas
|
|
565
|
+
sections={post.sections}
|
|
566
|
+
header={header}
|
|
567
|
+
context={context}
|
|
568
|
+
selectedId={selectedId}
|
|
569
|
+
viewport={viewport}
|
|
570
|
+
onSelect={setSelectedId}
|
|
571
|
+
onAddSection={() => setAddOpen(true)}
|
|
572
|
+
structuralTypes={structuralTypes}
|
|
573
|
+
/>
|
|
574
|
+
)}
|
|
550
575
|
</div>
|
|
551
576
|
|
|
552
577
|
{selected && (
|