@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +15 -32
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/LoginRoot.d.ts +54 -0
  7. package/dist/LoginRoot.d.ts.map +1 -0
  8. package/dist/LoginRoot.js +59 -0
  9. package/dist/LoginRoot.js.map +1 -0
  10. package/dist/__tests__/views/login-root.render.test.d.ts +2 -0
  11. package/dist/__tests__/views/login-root.render.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/login-root.render.test.js +79 -0
  13. package/dist/__tests__/views/login-root.render.test.js.map +1 -0
  14. package/dist/__tests__/views/use-site-preview.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/use-site-preview.render.test.js +86 -0
  17. package/dist/__tests__/views/use-site-preview.render.test.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/root-chrome.d.ts +15 -0
  23. package/dist/lib/root-chrome.d.ts.map +1 -0
  24. package/dist/lib/root-chrome.js +41 -0
  25. package/dist/lib/root-chrome.js.map +1 -0
  26. package/dist/views/HiddenLoginNotFound.d.ts +8 -0
  27. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -0
  28. package/dist/views/HiddenLoginNotFound.js +29 -0
  29. package/dist/views/HiddenLoginNotFound.js.map +1 -0
  30. package/dist/views/page-editor/EditorTopBar.d.ts +8 -1
  31. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  32. package/dist/views/page-editor/EditorTopBar.js +7 -3
  33. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  34. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  35. package/dist/views/page-editor/PageSectionEditor.js +14 -1
  36. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  37. package/dist/views/page-editor/SitePreviewFrame.d.ts +18 -0
  38. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -0
  39. package/dist/views/page-editor/SitePreviewFrame.js +45 -0
  40. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -0
  41. package/dist/views/page-editor/useSitePreview.d.ts +33 -0
  42. package/dist/views/page-editor/useSitePreview.d.ts.map +1 -0
  43. package/dist/views/page-editor/useSitePreview.js +72 -0
  44. package/dist/views/page-editor/useSitePreview.js.map +1 -0
  45. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.js +14 -1
  47. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/AdminRoot.tsx +26 -29
  50. package/src/LoginRoot.tsx +166 -0
  51. package/src/__tests__/views/login-root.render.test.tsx +115 -0
  52. package/src/__tests__/views/use-site-preview.render.test.tsx +122 -0
  53. package/src/index.ts +2 -0
  54. package/src/lib/root-chrome.ts +40 -0
  55. package/src/views/HiddenLoginNotFound.tsx +45 -0
  56. package/src/views/page-editor/EditorTopBar.tsx +31 -0
  57. package/src/views/page-editor/PageSectionEditor.tsx +33 -8
  58. package/src/views/page-editor/SitePreviewFrame.tsx +85 -0
  59. package/src/views/page-editor/useSitePreview.ts +117 -0
  60. 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
- <EditorCanvas
523
- sections={page.sections}
524
- selectedId={selectedId}
525
- viewport={viewport}
526
- onSelect={setSelectedId}
527
- onAddSection={() => setAddOpen(true)}
528
- structuralTypes={structuralTypes}
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
- <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 && (