@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/__tests__/views/collection-list-facets.render.test.d.ts +2 -0
  3. package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
  5. package/dist/__tests__/views/collection-list-facets.render.test.js.map +1 -0
  6. package/dist/__tests__/views/use-site-preview.render.test.d.ts +2 -0
  7. package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/use-site-preview.render.test.js +86 -0
  9. package/dist/__tests__/views/use-site-preview.render.test.js.map +1 -0
  10. package/dist/views/CollectionList.d.ts.map +1 -1
  11. package/dist/views/CollectionList.js +121 -11
  12. package/dist/views/CollectionList.js.map +1 -1
  13. package/dist/views/page-editor/EditorTopBar.d.ts +8 -1
  14. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  15. package/dist/views/page-editor/EditorTopBar.js +7 -3
  16. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  17. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  18. package/dist/views/page-editor/PageSectionEditor.js +14 -1
  19. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  20. package/dist/views/page-editor/SitePreviewFrame.d.ts +18 -0
  21. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -0
  22. package/dist/views/page-editor/SitePreviewFrame.js +45 -0
  23. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -0
  24. package/dist/views/page-editor/useSitePreview.d.ts +33 -0
  25. package/dist/views/page-editor/useSitePreview.d.ts.map +1 -0
  26. package/dist/views/page-editor/useSitePreview.js +72 -0
  27. package/dist/views/page-editor/useSitePreview.js.map +1 -0
  28. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  29. package/dist/views/post-editor/PostSectionEditor.js +14 -1
  30. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/views/collection-list-facets.render.test.tsx +148 -0
  33. package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
  34. package/src/views/CollectionList.tsx +187 -23
  35. package/src/views/page-editor/EditorTopBar.tsx +31 -0
  36. package/src/views/page-editor/PageSectionEditor.tsx +33 -8
  37. package/src/views/page-editor/SitePreviewFrame.tsx +85 -0
  38. package/src/views/page-editor/useSitePreview.ts +117 -0
  39. 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
- <PostEditorCanvas
541
- sections={post.sections}
542
- header={header}
543
- context={context}
544
- selectedId={selectedId}
545
- viewport={viewport}
546
- onSelect={setSelectedId}
547
- onAddSection={() => setAddOpen(true)}
548
- structuralTypes={structuralTypes}
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 && (