@growth-labs/cms 0.8.38 → 0.8.40

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 (87) hide show
  1. package/dist/engine/fronts-publish.d.ts +29 -0
  2. package/dist/engine/fronts-publish.d.ts.map +1 -1
  3. package/dist/engine/fronts-publish.js +58 -0
  4. package/dist/engine/fronts-publish.js.map +1 -1
  5. package/dist/engine/publication-current-guard.d.ts +19 -0
  6. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  7. package/dist/engine/publication-current-guard.js +75 -0
  8. package/dist/engine/publication-current-guard.js.map +1 -1
  9. package/dist/engine/publisher.d.ts +3 -0
  10. package/dist/engine/publisher.d.ts.map +1 -1
  11. package/dist/engine/publisher.js +15 -2
  12. package/dist/engine/publisher.js.map +1 -1
  13. package/dist/foundry-emit.d.ts +59 -6
  14. package/dist/foundry-emit.d.ts.map +1 -1
  15. package/dist/foundry-emit.js +16 -1
  16. package/dist/foundry-emit.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts.map +1 -1
  18. package/dist/routes/content-insights.js +6 -0
  19. package/dist/routes/content-insights.js.map +1 -1
  20. package/dist/routes/content.d.ts +3 -0
  21. package/dist/routes/content.d.ts.map +1 -1
  22. package/dist/routes/content.js +1 -0
  23. package/dist/routes/content.js.map +1 -1
  24. package/dist/routes/foundry-publish.d.ts.map +1 -1
  25. package/dist/routes/foundry-publish.js +101 -12
  26. package/dist/routes/foundry-publish.js.map +1 -1
  27. package/dist/routes/fronts-publish.d.ts +2 -0
  28. package/dist/routes/fronts-publish.d.ts.map +1 -1
  29. package/dist/routes/fronts-publish.js +47 -1
  30. package/dist/routes/fronts-publish.js.map +1 -1
  31. package/dist/routes/media-lib.d.ts +2 -0
  32. package/dist/routes/media-lib.d.ts.map +1 -1
  33. package/dist/routes/media-lib.js +6 -0
  34. package/dist/routes/media-lib.js.map +1 -1
  35. package/dist/schema/insights-ingest.d.ts +88 -88
  36. package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
  37. package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
  38. package/dist/ui/api/fronts/publish-callback.js +1 -0
  39. package/dist/ui/api/fronts/publish-callback.js.map +1 -1
  40. package/dist/ui/editor/extensions.d.ts +1 -1
  41. package/dist/ui/editor/extensions.d.ts.map +1 -1
  42. package/dist/ui/editor/extensions.js +15 -1
  43. package/dist/ui/editor/extensions.js.map +1 -1
  44. package/dist/ui/editor/heading-level.d.ts +4 -0
  45. package/dist/ui/editor/heading-level.d.ts.map +1 -0
  46. package/dist/ui/editor/heading-level.js +13 -0
  47. package/dist/ui/editor/heading-level.js.map +1 -0
  48. package/dist/ui/editor/portable-text.d.ts.map +1 -1
  49. package/dist/ui/editor/portable-text.js +3 -2
  50. package/dist/ui/editor/portable-text.js.map +1 -1
  51. package/dist/ui/editor/serialize.d.ts.map +1 -1
  52. package/dist/ui/editor/serialize.js +4 -3
  53. package/dist/ui/editor/serialize.js.map +1 -1
  54. package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
  55. package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
  56. package/dist/ui/preview/SitePreviewFrame.js +86 -0
  57. package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
  58. package/dist/ui/preview/preview-layout.d.ts +6 -0
  59. package/dist/ui/preview/preview-layout.d.ts.map +1 -1
  60. package/dist/ui/preview/preview-layout.js +15 -0
  61. package/dist/ui/preview/preview-layout.js.map +1 -1
  62. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  63. package/dist/ui/screens/EditorScreen.js +61 -25
  64. package/dist/ui/screens/EditorScreen.js.map +1 -1
  65. package/dist/ui/screens/public-url.d.ts +12 -0
  66. package/dist/ui/screens/public-url.d.ts.map +1 -1
  67. package/dist/ui/screens/public-url.js +30 -0
  68. package/dist/ui/screens/public-url.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/engine/fronts-publish.ts +88 -0
  71. package/src/engine/publication-current-guard.ts +103 -0
  72. package/src/engine/publisher.ts +22 -3
  73. package/src/foundry-emit.ts +17 -0
  74. package/src/routes/content-insights.ts +6 -0
  75. package/src/routes/content.ts +4 -0
  76. package/src/routes/foundry-publish.ts +134 -12
  77. package/src/routes/fronts-publish.ts +57 -0
  78. package/src/routes/media-lib.ts +6 -0
  79. package/src/ui/api/fronts/publish-callback.ts +3 -0
  80. package/src/ui/editor/extensions.ts +16 -1
  81. package/src/ui/editor/heading-level.ts +14 -0
  82. package/src/ui/editor/portable-text.ts +3 -2
  83. package/src/ui/editor/serialize.ts +4 -3
  84. package/src/ui/preview/SitePreviewFrame.tsx +111 -0
  85. package/src/ui/preview/preview-layout.ts +17 -0
  86. package/src/ui/screens/EditorScreen.tsx +76 -27
  87. package/src/ui/screens/public-url.ts +36 -0
@@ -2,13 +2,13 @@
2
2
  //
3
3
  // Ported from editor.jsx:
4
4
  // - Header: back-to-Content, type icon, status pill, autosaved indicator,
5
- // preview-position segment (right/bottom/off), Preview (open signed URL),
6
- // Publish action
5
+ // preview-position segment (right/bottom/off), Preview (real-site draft in
6
+ // a new tab), Publish action
7
7
  // - Work area: <ContentForm/> in the main column + 340px empty P3 inspector slot
8
- // - Live-preview pane: <DraftPage doc={...} device={...} /> with device toggle
9
- // (desktop/tablet/mobile)
10
- // - "Open in new tab" mints a HMAC preview token via POST /content/:id/preview-token
11
- // and opens the signed URL
8
+ // - Live-preview pane: the real site's draft render (<SitePreviewFrame/>,
9
+ // `previewUrlFor`) at true device widths, reloaded after every save; types
10
+ // the site cannot render as a draft keep the approximate <DraftPage/>
11
+ // - "Preview" saves, then opens the same real-site draft render in a new tab
12
12
  // - New docs (docRef.id === null) create on first save via ContentForm; the
13
13
  // onDocCreated callback swaps to update mode
14
14
  //
@@ -16,7 +16,7 @@
16
16
  // ContentForm/DraftPage/preview-layout are tested separately; the Tiptap Rte
17
17
  // is Playwright-smoked only.
18
18
 
19
- import { useCallback, useState } from 'react'
19
+ import { useCallback, useEffect, useRef, useState } from 'react'
20
20
  import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
21
21
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
22
22
  import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
@@ -30,7 +30,9 @@ import { Inspector } from '../inspector/Inspector.js'
30
30
  import type { DraftDoc } from '../preview/draft-page.js'
31
31
  import { DraftPage } from '../preview/draft-page.js'
32
32
  import type { PreviewDevice } from '../preview/preview-layout.js'
33
+ import { SitePreviewFrame } from '../preview/SitePreviewFrame.js'
33
34
  import type { ContentView } from './content-view.js'
35
+ import { hasSitePreview, previewUrlFor } from './public-url.js'
34
36
 
35
37
  // ---------------------------------------------------------------------------
36
38
  // Props
@@ -155,20 +157,45 @@ export function EditorScreen({
155
157
  // Share modal — true when the Share button is clicked in the editor header
156
158
  const [shareOpen, setShareOpen] = useState(false)
157
159
 
158
- // Mint a preview token and open the signed URL in a new tab
159
- const handlePreviewInTab = useCallback(async () => {
160
- const id = docId
161
- if (!id) return
162
- try {
163
- const res = await fetch(`/admin/api/content/${id}/preview-token`, { method: 'POST' })
164
- if (!res.ok) return
165
- const body = (await res.json()) as { token?: string; url?: string }
166
- const url = body.url ?? body.token
167
- if (url) window.open(url, '_blank')
168
- } catch {
169
- // non-fatal
160
+ // The real-site preview follows the slug as last SAVED, so typing a new slug
161
+ // does not point the frame at a URL the draft does not answer to yet.
162
+ const latestSlug = useRef(previewDoc.slug)
163
+ latestSlug.current = previewDoc.slug
164
+ const [savedSlug, setSavedSlug] = useState('')
165
+ useEffect(() => {
166
+ if (!savedSlug && previewDoc.slug && saveState.lastSavedAt === null) {
167
+ setSavedSlug(previewDoc.slug)
170
168
  }
171
- }, [docId])
169
+ }, [previewDoc.slug, savedSlug, saveState.lastSavedAt])
170
+ useEffect(() => {
171
+ if (saveState.lastSavedAt !== null) setSavedSlug(latestSlug.current)
172
+ }, [saveState.lastSavedAt])
173
+ const sitePreviewUrl = docId
174
+ ? previewUrlFor({ type: contentType, slug: savedSlug, id: docId }, siteBase)
175
+ : null
176
+
177
+ // Preview in a new tab: open the tab inside the click (popup blockers allow
178
+ // that), save, then point it at the real-site draft render once the save
179
+ // settles so the tab shows the latest edits.
180
+ const pendingPreviewTab = useRef<{ tab: Window; since: number | null } | null>(null)
181
+ const handlePreviewInTab = useCallback(() => {
182
+ if (!docId) return
183
+ const tab = window.open('about:blank', '_blank')
184
+ if (!tab) return
185
+ pendingPreviewTab.current = { tab, since: saveState.lastSavedAt }
186
+ setSaveRequest((value) => value + 1)
187
+ }, [docId, saveState.lastSavedAt])
188
+ useEffect(() => {
189
+ const pending = pendingPreviewTab.current
190
+ if (!pending || saveState.saving) return
191
+ if (saveState.lastSavedAt === pending.since && !saveState.saveError) return
192
+ pendingPreviewTab.current = null
193
+ const url = docId
194
+ ? previewUrlFor({ type: contentType, slug: latestSlug.current, id: docId }, siteBase)
195
+ : null
196
+ if (url) pending.tab.location.href = url
197
+ else pending.tab.close()
198
+ }, [saveState, docId, contentType, siteBase])
172
199
 
173
200
  const handleOpenAnalytics = useCallback(() => {
174
201
  if (!previewDoc.slug) return
@@ -337,15 +364,16 @@ export function EditorScreen({
337
364
  </fieldset>
338
365
  )}
339
366
 
340
- {/* Open preview in new tab */}
341
- {previewPos !== 'off' && docId && (
367
+ {/* Preview on the real site, in a new tab */}
368
+ {sitePreviewUrl && (
342
369
  <button
343
370
  type="button"
344
- onClick={() => void handlePreviewInTab()}
345
- style={actionBtn}
346
- title="Open preview in new tab"
371
+ onClick={handlePreviewInTab}
372
+ style={saveBtn}
373
+ title="Save and open this draft on the live site template in a new tab"
347
374
  >
348
- <Icon name="globe" size={14} />
375
+ <Icon name="eye" size={14} />
376
+ Preview
349
377
  </button>
350
378
  )}
351
379
 
@@ -432,7 +460,20 @@ export function EditorScreen({
432
460
  {/* Live-preview pane */}
433
461
  {previewPos !== 'off' && (
434
462
  <div style={grid.previewPane}>
435
- <DraftPage doc={previewDoc} device={device} />
463
+ {sitePreviewUrl ? (
464
+ <SitePreviewFrame
465
+ url={sitePreviewUrl}
466
+ device={device}
467
+ version={saveState.lastSavedAt}
468
+ title={previewDoc.title}
469
+ />
470
+ ) : hasSitePreview(contentType) ? (
471
+ <p style={previewEmptyNote}>
472
+ Save the draft with a title to see it on the live site template.
473
+ </p>
474
+ ) : (
475
+ <DraftPage doc={previewDoc} device={device} />
476
+ )}
436
477
  </div>
437
478
  )}
438
479
  </div>
@@ -744,6 +785,14 @@ const legendHidden: React.CSSProperties = {
744
785
  display: 'none',
745
786
  }
746
787
 
788
+ const previewEmptyNote: React.CSSProperties = {
789
+ margin: 'auto',
790
+ padding: 24,
791
+ fontSize: 13,
792
+ color: 'var(--ink-muted)',
793
+ textAlign: 'center',
794
+ }
795
+
747
796
  // Void the unused variable warning by referencing the label map in a subtle
748
797
  // way (it's used implicitly via the type system; keep for completeness).
749
798
  void CONTENT_TYPE_LABELS
@@ -81,3 +81,39 @@ export function publicUrlFor(row: { type: ContentType; slug: string }, siteBase?
81
81
  const base = siteBase.replace(/\/+$/, '')
82
82
  return `${base}${path}`
83
83
  }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // previewUrlFor
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Public routes that render an unpublished draft for a signed-in publisher via
91
+ * `?preview=<id>` (apps/web publication-routes/article/[slug].astro and
92
+ * videos/[slug].astro). Video uses `/videos/` directly: `/video/<slug>` 301s
93
+ * there and drops the query. The other types have no draft render on the site.
94
+ */
95
+ const SITE_PREVIEW_PATH: Partial<Record<ContentType, (slug: string) => string>> = {
96
+ article: (slug) => `/article/${slug}/`,
97
+ video: (slug) => `/videos/${slug}/`,
98
+ }
99
+
100
+ /** Whether the site renders this content type as a draft (see SITE_PREVIEW_PATH). */
101
+ export function hasSitePreview(type: ContentType): boolean {
102
+ return SITE_PREVIEW_PATH[type] !== undefined
103
+ }
104
+
105
+ /**
106
+ * The draft preview URL on the real site: the public page for the item's
107
+ * saved slug, rendered from the current row with the live template and CSS.
108
+ * Returns `null` for a type the site cannot render as a draft.
109
+ */
110
+ export function previewUrlFor(
111
+ row: { type: ContentType; slug: string; id: string },
112
+ siteBase?: string,
113
+ ): string | null {
114
+ const path = SITE_PREVIEW_PATH[row.type]
115
+ const cleanSlug = row.slug.replace(/^\/+/, '')
116
+ if (!path || !cleanSlug || !row.id) return null
117
+ const base = siteBase ? siteBase.replace(/\/+$/, '') : ''
118
+ return `${base}${path(cleanSlug)}?preview=${encodeURIComponent(row.id)}`
119
+ }