@growth-labs/cms 0.8.35 → 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.
- package/dist/engine/fronts-publish.d.ts +29 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +58 -0
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +19 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +75 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +15 -2
- package/dist/engine/publisher.js.map +1 -1
- package/dist/foundry-emit.d.ts +106 -4
- package/dist/foundry-emit.d.ts.map +1 -1
- package/dist/foundry-emit.js +28 -0
- package/dist/foundry-emit.js.map +1 -1
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +18 -28
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/content.d.ts +3 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +1 -0
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +191 -8
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +3 -1
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +47 -1
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/routes/media-lib.d.ts +4 -0
- package/dist/routes/media-lib.d.ts.map +1 -1
- package/dist/routes/media-lib.js +23 -0
- package/dist/routes/media-lib.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +88 -88
- package/dist/ui/api/fronts/publish-callback.d.ts +1 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -1
- package/dist/ui/api/fronts/publish-callback.js +1 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -1
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/extensions.d.ts.map +1 -1
- package/dist/ui/editor/extensions.js +15 -1
- package/dist/ui/editor/extensions.js.map +1 -1
- package/dist/ui/editor/heading-level.d.ts +4 -0
- package/dist/ui/editor/heading-level.d.ts.map +1 -0
- package/dist/ui/editor/heading-level.js +13 -0
- package/dist/ui/editor/heading-level.js.map +1 -0
- package/dist/ui/editor/portable-text.d.ts.map +1 -1
- package/dist/ui/editor/portable-text.js +3 -2
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.d.ts.map +1 -1
- package/dist/ui/editor/serialize.js +4 -3
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/preview/SitePreviewFrame.d.ts +11 -0
- package/dist/ui/preview/SitePreviewFrame.d.ts.map +1 -0
- package/dist/ui/preview/SitePreviewFrame.js +86 -0
- package/dist/ui/preview/SitePreviewFrame.js.map +1 -0
- package/dist/ui/preview/preview-layout.d.ts +6 -0
- package/dist/ui/preview/preview-layout.d.ts.map +1 -1
- package/dist/ui/preview/preview-layout.js +15 -0
- package/dist/ui/preview/preview-layout.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +61 -25
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/public-url.d.ts +12 -0
- package/dist/ui/screens/public-url.d.ts.map +1 -1
- package/dist/ui/screens/public-url.js +30 -0
- package/dist/ui/screens/public-url.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/fronts-publish.ts +88 -0
- package/src/engine/publication-current-guard.ts +103 -0
- package/src/engine/publisher.ts +22 -3
- package/src/foundry-emit.ts +36 -0
- package/src/routes/content-insights.ts +23 -28
- package/src/routes/content.ts +4 -0
- package/src/routes/foundry-publish.ts +228 -8
- package/src/routes/fronts-publish.ts +61 -1
- package/src/routes/media-lib.ts +24 -0
- package/src/ui/api/fronts/publish-callback.ts +3 -0
- package/src/ui/editor/extensions.ts +16 -1
- package/src/ui/editor/heading-level.ts +14 -0
- package/src/ui/editor/portable-text.ts +3 -2
- package/src/ui/editor/serialize.ts +4 -3
- package/src/ui/preview/SitePreviewFrame.tsx +111 -0
- package/src/ui/preview/preview-layout.ts +17 -0
- package/src/ui/screens/EditorScreen.tsx +76 -27
- package/src/ui/screens/public-url.ts +36 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/ui/preview/SitePreviewFrame.tsx — the real site page for a draft.
|
|
2
|
+
//
|
|
3
|
+
// Embeds the publication's own draft render (`previewUrlFor`, a same-origin
|
|
4
|
+
// route gated on publisher access) so the editor sees the live template and
|
|
5
|
+
// CSS, not a copy of them. The frame renders at the device's true viewport
|
|
6
|
+
// width (SITE_FRAME_WIDTHS) and is scaled to fit the pane, so the site's own
|
|
7
|
+
// breakpoints pick the layout. Each save bumps `version`, which reloads the
|
|
8
|
+
// frame in place (a reload keeps the reader's scroll position).
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import { type PreviewDevice, SITE_FRAME_WIDTHS, siteFrameScale } from './preview-layout.js'
|
|
12
|
+
|
|
13
|
+
export interface SitePreviewFrameProps {
|
|
14
|
+
/** Draft preview URL from `previewUrlFor`. */
|
|
15
|
+
url: string
|
|
16
|
+
device: PreviewDevice
|
|
17
|
+
/** Changes after each save; a new value reloads the frame. */
|
|
18
|
+
version: number | null
|
|
19
|
+
title: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SitePreviewFrame({ url, device, version, title }: SitePreviewFrameProps) {
|
|
23
|
+
const paneRef = useRef<HTMLDivElement | null>(null)
|
|
24
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
|
25
|
+
const [pane, setPane] = useState({ width: 0, height: 0 })
|
|
26
|
+
const [loading, setLoading] = useState(true)
|
|
27
|
+
const shown = useRef({ url, version })
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const node = paneRef.current
|
|
31
|
+
if (!node || typeof ResizeObserver === 'undefined') return
|
|
32
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
33
|
+
setPane({ width: entry.contentRect.width, height: entry.contentRect.height })
|
|
34
|
+
})
|
|
35
|
+
observer.observe(node)
|
|
36
|
+
return () => observer.disconnect()
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const previous = shown.current
|
|
41
|
+
shown.current = { url, version }
|
|
42
|
+
// A new URL (the saved slug changed) navigates through `src`; reloading
|
|
43
|
+
// here too would reload the old page over that navigation.
|
|
44
|
+
if (previous.url !== url) {
|
|
45
|
+
setLoading(true)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (previous.version === version) return
|
|
49
|
+
const frameWindow = frameRef.current?.contentWindow
|
|
50
|
+
if (!frameWindow) return
|
|
51
|
+
setLoading(true)
|
|
52
|
+
try {
|
|
53
|
+
frameWindow.location.reload()
|
|
54
|
+
} catch {
|
|
55
|
+
// The frame navigated off-origin (a link clicked inside it); reset it.
|
|
56
|
+
if (frameRef.current) frameRef.current.src = url
|
|
57
|
+
}
|
|
58
|
+
}, [version, url])
|
|
59
|
+
|
|
60
|
+
const viewportWidth = SITE_FRAME_WIDTHS[device]
|
|
61
|
+
const scale = siteFrameScale(pane.width, device)
|
|
62
|
+
const frameHeight = pane.height > 0 ? pane.height / scale : 800
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div ref={paneRef} style={paneStyle}>
|
|
66
|
+
<iframe
|
|
67
|
+
ref={frameRef}
|
|
68
|
+
src={url}
|
|
69
|
+
title={`Preview: ${title || 'Untitled'}`}
|
|
70
|
+
onLoad={() => setLoading(false)}
|
|
71
|
+
style={{
|
|
72
|
+
width: viewportWidth,
|
|
73
|
+
height: frameHeight,
|
|
74
|
+
border: 'none',
|
|
75
|
+
background: '#fff',
|
|
76
|
+
transform: `scale(${scale})`,
|
|
77
|
+
transformOrigin: 'top left',
|
|
78
|
+
position: 'absolute',
|
|
79
|
+
top: 0,
|
|
80
|
+
left: Math.max(0, (pane.width - viewportWidth * scale) / 2),
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
{loading && (
|
|
84
|
+
<span style={badgeStyle} role="status">
|
|
85
|
+
Updating preview…
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const paneStyle: React.CSSProperties = {
|
|
93
|
+
position: 'relative',
|
|
94
|
+
flex: 1,
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
background: 'var(--panel)',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const badgeStyle: React.CSSProperties = {
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
top: 10,
|
|
102
|
+
right: 12,
|
|
103
|
+
padding: '3px 9px',
|
|
104
|
+
borderRadius: 20,
|
|
105
|
+
fontSize: 11,
|
|
106
|
+
fontWeight: 600,
|
|
107
|
+
background: 'var(--surface-raised-nav)',
|
|
108
|
+
color: 'var(--ink-muted)',
|
|
109
|
+
border: '1px solid var(--border)',
|
|
110
|
+
pointerEvents: 'none',
|
|
111
|
+
}
|
|
@@ -47,3 +47,20 @@ export function previewLayout(
|
|
|
47
47
|
device,
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
// ---- Real-site preview frame ---------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** The viewport width each device renders the real site at. The site's own
|
|
54
|
+
* breakpoints decide the layout, so desktop needs a desktop-sized viewport even
|
|
55
|
+
* when the pane is narrower. */
|
|
56
|
+
export const SITE_FRAME_WIDTHS: Record<PreviewDevice, number> = {
|
|
57
|
+
mobile: 390,
|
|
58
|
+
tablet: 768,
|
|
59
|
+
desktop: 1280,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Scale that fits a device viewport into the pane width; never enlarges. */
|
|
63
|
+
export function siteFrameScale(paneWidth: number, device: PreviewDevice): number {
|
|
64
|
+
if (!Number.isFinite(paneWidth) || paneWidth <= 0) return 1
|
|
65
|
+
return Math.min(1, paneWidth / SITE_FRAME_WIDTHS[device])
|
|
66
|
+
}
|
|
@@ -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 (
|
|
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:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
}, [
|
|
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
|
-
{/*
|
|
341
|
-
{
|
|
367
|
+
{/* Preview on the real site, in a new tab */}
|
|
368
|
+
{sitePreviewUrl && (
|
|
342
369
|
<button
|
|
343
370
|
type="button"
|
|
344
|
-
onClick={
|
|
345
|
-
style={
|
|
346
|
-
title="
|
|
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="
|
|
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
|
-
|
|
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
|
+
}
|