@actuate-media/cms-admin 0.46.0 → 0.48.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 +17 -0
- package/dist/__tests__/views/collection-list-facets.render.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
- package/dist/__tests__/views/collection-list-facets.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/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +121 -11
- package/dist/views/CollectionList.js.map +1 -1
- 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/__tests__/views/collection-list-facets.render.test.tsx +148 -0
- package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
- package/src/views/CollectionList.tsx +187 -23
- 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,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 && (
|