fix: properly handles nested routes for live preview (#3586)
This commit is contained in:
@@ -8,7 +8,7 @@ keywords: live preview, preview, live, iframe, iframe preview, visual editing, d
|
|||||||
|
|
||||||
**With Live Preview you can render your front-end application directly within the Admin panel. As you type, your changes take effect in real-time. No need to save a draft or publish your changes.**
|
**With Live Preview you can render your front-end application directly within the Admin panel. As you type, your changes take effect in real-time. No need to save a draft or publish your changes.**
|
||||||
|
|
||||||
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through `window.postMessage` events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives.
|
Live Preview works by rendering an iframe on the page that loads your front-end application. The Admin panel communicates with your app through [`window.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) events. These events are emitted every time a change is made to the document. Your app then listens for these events and re-renders itself with the data it receives.
|
||||||
|
|
||||||
{/* IMAGE OF LIVE PREVIEW HERE */}
|
{/* IMAGE OF LIVE PREVIEW HERE */}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Here is an example of using a function that returns a dynamic URL:
|
|||||||
documentInfo,
|
documentInfo,
|
||||||
locale
|
locale
|
||||||
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
|
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
|
||||||
documentInfo.slug === 'posts' ? `/posts/${data.slug}` : `/${data.slug}
|
documentInfo.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
|
||||||
`}?locale=${locale}`, // Localization query param
|
`}?locale=${locale}`, // Localization query param
|
||||||
collections: ['pages'],
|
collections: ['pages'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export const Pages: CollectionConfig = {
|
|||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `${process.env.PAYLOAD_PUBLIC_SITE_URL}/${data.slug}`,
|
url: ({ data }) =>
|
||||||
|
`${process.env.PAYLOAD_PUBLIC_SITE_URL}${data.slug !== 'home' ? `/${data.slug}` : ''}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
|
|||||||
@@ -58,13 +58,16 @@ const Preview: React.FC<
|
|||||||
const values = reduceFieldsToValues(fields, true)
|
const values = reduceFieldsToValues(fields, true)
|
||||||
|
|
||||||
// TODO: only send `fieldSchemaToJSON` one time
|
// TODO: only send `fieldSchemaToJSON` one time
|
||||||
const message = JSON.stringify({ data: values, fieldSchemaJSON, type: 'livePreview' })
|
const message = JSON.stringify({
|
||||||
|
data: values,
|
||||||
|
fieldSchemaJSON,
|
||||||
|
type: 'payload-live-preview',
|
||||||
|
})
|
||||||
|
|
||||||
// external window
|
// external window
|
||||||
if (isPopupOpen) {
|
if (isPopupOpen) {
|
||||||
setIframeHasLoaded(false)
|
setIframeHasLoaded(false)
|
||||||
|
if (popupRef.current) {
|
||||||
if (popupHasLoaded && popupRef.current) {
|
|
||||||
popupRef.current.postMessage(message, url)
|
popupRef.current.postMessage(message, url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
|
|||||||
: livePreviewConfig?.url
|
: livePreviewConfig?.url
|
||||||
|
|
||||||
const popupState = usePopupWindow({
|
const popupState = usePopupWindow({
|
||||||
eventType: 'livePreview',
|
eventType: 'payload-live-preview',
|
||||||
href: url,
|
url,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { apiURL, data, permissions } = props
|
const { apiURL, data, permissions } = props
|
||||||
|
|||||||
@@ -14,28 +14,29 @@ export interface PopupMessage {
|
|||||||
|
|
||||||
export const usePopupWindow = (props: {
|
export const usePopupWindow = (props: {
|
||||||
eventType?: string
|
eventType?: string
|
||||||
href: string
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void>
|
onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void>
|
||||||
|
url: string
|
||||||
}): {
|
}): {
|
||||||
isPopupOpen: boolean
|
isPopupOpen: boolean
|
||||||
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
|
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
|
||||||
popupHasLoaded: boolean
|
popupHasLoaded: boolean
|
||||||
popupRef?: React.MutableRefObject<Window | null>
|
popupRef?: React.MutableRefObject<Window | null>
|
||||||
} => {
|
} => {
|
||||||
const { eventType, href, onMessage } = props
|
const { eventType, onMessage, url } = props
|
||||||
const isReceivingMessage = useRef(false)
|
const isReceivingMessage = useRef(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
|
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
|
||||||
const { serverURL } = useConfig()
|
const { serverURL } = useConfig()
|
||||||
const popupRef = useRef<Window | null>(null)
|
const popupRef = useRef<Window | null>(null)
|
||||||
|
const hasAttachedMessageListener = useRef(false)
|
||||||
|
|
||||||
// Optionally broadcast messages back out to the parent component
|
// Optionally broadcast messages back out to the parent component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const receiveMessage = async (event: MessageEvent): Promise<void> => {
|
const receiveMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
if (
|
if (
|
||||||
event.origin !== window.location.origin ||
|
event.origin !== window.location.origin ||
|
||||||
event.origin !== href ||
|
event.origin !== url ||
|
||||||
event.origin !== serverURL
|
event.origin !== serverURL
|
||||||
) {
|
) {
|
||||||
// console.warn(`Message received by ${event.origin}; IGNORED.`) // eslint-disable-line no-console
|
// console.warn(`Message received by ${event.origin}; IGNORED.`) // eslint-disable-line no-console
|
||||||
@@ -53,12 +54,14 @@ export const usePopupWindow = (props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOpen && popupRef.current) {
|
||||||
window.addEventListener('message', receiveMessage, false)
|
window.addEventListener('message', receiveMessage, false)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', receiveMessage)
|
window.removeEventListener('message', receiveMessage)
|
||||||
}
|
}
|
||||||
}, [onMessage, eventType, href, serverURL])
|
}, [onMessage, eventType, url, serverURL, isOpen])
|
||||||
|
|
||||||
// Customize the size, position, and style of the popup window
|
// Customize the size, position, and style of the popup window
|
||||||
const openPopupWindow = useCallback(
|
const openPopupWindow = useCallback(
|
||||||
@@ -93,23 +96,36 @@ export const usePopupWindow = (props: {
|
|||||||
return strCopy
|
return strCopy
|
||||||
}, '')
|
}, '')
|
||||||
.slice(0, -1) // remove last ',' (comma)
|
.slice(0, -1) // remove last ',' (comma)
|
||||||
const newWindow = window.open(href, '_blank', popupOptions)
|
|
||||||
|
const newWindow = window.open(url, '_blank', popupOptions)
|
||||||
|
|
||||||
popupRef.current = newWindow
|
popupRef.current = newWindow
|
||||||
|
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
},
|
},
|
||||||
[href],
|
[url],
|
||||||
)
|
)
|
||||||
|
|
||||||
// the only cross-origin way of detecting when a popup window has loaded
|
// the only cross-origin way of detecting when a popup window has loaded
|
||||||
// we catch a message event that the site rendered within the popup window fires
|
// we catch a message event that the site rendered within the popup window fires
|
||||||
// there is no way in js to add an event listener to a popup window across domains
|
// there is no way in js to add an event listener to a popup window across domains
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasAttachedMessageListener.current) return
|
||||||
|
hasAttachedMessageListener.current = true
|
||||||
|
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.origin === href && event.data === 'ready') {
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.startsWith(event.origin) &&
|
||||||
|
data.type === eventType &&
|
||||||
|
data.popupReady &&
|
||||||
|
!popupHasLoaded
|
||||||
|
) {
|
||||||
setPopupHasLoaded(true)
|
setPopupHasLoaded(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [href])
|
}, [url, eventType, popupHasLoaded])
|
||||||
|
|
||||||
// this is the most stable and widely supported way to check if a popup window is no longer open
|
// this is the most stable and widely supported way to check if a popup window is no longer open
|
||||||
// we poll its ref every x ms and use the popup window's `closed` property
|
// we poll its ref every x ms and use the popup window's `closed` property
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export default buildConfigWithDefaults({
|
|||||||
// The Live Preview config is inherited from the top down
|
// The Live Preview config is inherited from the top down
|
||||||
url: ({ data, documentInfo }) =>
|
url: ({ data, documentInfo }) =>
|
||||||
`http://localhost:3001${
|
`http://localhost:3001${
|
||||||
documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : ''
|
documentInfo?.slug && documentInfo.slug !== 'pages' ? `/${documentInfo.slug}` : ''
|
||||||
}/${data?.slug}`,
|
}${data?.slug && data.slug !== 'home' ? `/${data.slug}` : ''}`,
|
||||||
breakpoints: [
|
breakpoints: [
|
||||||
{
|
{
|
||||||
label: 'Mobile',
|
label: 'Mobile',
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Post as PostType } from '@/payload-types'
|
||||||
|
import { useLivePreview } from '../../../../../../../packages/live-preview-react'
|
||||||
|
import React from 'react'
|
||||||
|
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
|
||||||
|
import { Blocks } from '@/app/_components/Blocks'
|
||||||
|
import { PostHero } from '@/app/_heros/PostHero'
|
||||||
|
|
||||||
|
export const PostClient: React.FC<{
|
||||||
|
post: PostType
|
||||||
|
}> = ({ post: initialPost }) => {
|
||||||
|
const { data } = useLivePreview<PostType>({
|
||||||
|
initialData: initialPost,
|
||||||
|
serverURL: PAYLOAD_SERVER_URL,
|
||||||
|
depth: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<PostHero post={data} />
|
||||||
|
<Blocks blocks={data?.layout} />
|
||||||
|
<Blocks
|
||||||
|
disableTopPadding
|
||||||
|
blocks={[
|
||||||
|
{
|
||||||
|
blockType: 'relatedPosts',
|
||||||
|
blockName: 'Related Posts',
|
||||||
|
relationTo: 'posts',
|
||||||
|
introContent: [
|
||||||
|
{
|
||||||
|
type: 'h4',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'Related posts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'p',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'The posts displayed here are individually selected for this page. Admins can select any number of related posts to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate posts by category complete with pagination. To manage related posts, ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
url: `/admin/collections/posts/${data?.id}`,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'navigate to the admin dashboard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
docs: data?.relatedPosts,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,18 +4,9 @@ import { notFound } from 'next/navigation'
|
|||||||
import { Post } from '../../../../payload-types'
|
import { Post } from '../../../../payload-types'
|
||||||
import { fetchDoc } from '../../../_api/fetchDoc'
|
import { fetchDoc } from '../../../_api/fetchDoc'
|
||||||
import { fetchDocs } from '../../../_api/fetchDocs'
|
import { fetchDocs } from '../../../_api/fetchDocs'
|
||||||
import { Blocks } from '../../../_components/Blocks'
|
import { PostClient } from './page.client'
|
||||||
import { PostHero } from '../../../_heros/PostHero'
|
|
||||||
|
|
||||||
export default async function Post(args: {
|
|
||||||
params: {
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
params: { slug = 'home' },
|
|
||||||
} = args
|
|
||||||
|
|
||||||
|
export default async function Post({ params: { slug = '' } }) {
|
||||||
let post: Post | null = null
|
let post: Post | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -31,55 +22,7 @@ export default async function Post(args: {
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layout, relatedPosts } = post
|
return <PostClient post={post} />
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<PostHero post={post} />
|
|
||||||
<Blocks blocks={layout} />
|
|
||||||
<Blocks
|
|
||||||
disableTopPadding
|
|
||||||
blocks={[
|
|
||||||
{
|
|
||||||
blockType: 'relatedPosts',
|
|
||||||
blockName: 'Related Posts',
|
|
||||||
relationTo: 'posts',
|
|
||||||
introContent: [
|
|
||||||
{
|
|
||||||
type: 'h4',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: 'Related posts',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'p',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: 'The posts displayed here are individually selected for this page. Admins can select any number of related posts to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate posts by category complete with pagination. To manage related posts, ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'link',
|
|
||||||
url: `/admin/collections/posts/${post.id}`,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
text: 'navigate to the admin dashboard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
docs: relatedPosts,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
margin-bottom: calc(var(--base) * 1.5);
|
margin-bottom: calc(var(--base) * 1.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
|
|||||||
export const PostHero: React.FC<{
|
export const PostHero: React.FC<{
|
||||||
post: Post
|
post: Post
|
||||||
}> = ({ post }) => {
|
}> = ({ post }) => {
|
||||||
const { id, title, meta: { image: metaImage, description } = {}, createdAt } = post
|
const { id, meta: { image: metaImage, description } = {}, createdAt } = post
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Gutter className={classes.postHero}>
|
<Gutter className={classes.postHero}>
|
||||||
<div className={classes.content}>
|
<div className={classes.content}>
|
||||||
<h1 className={classes.title}>{title}</h1>
|
<RichText content={post?.hero?.richText} className={classes.richText} />
|
||||||
<p className={classes.meta}>
|
<p className={classes.meta}>
|
||||||
{createdAt && (
|
{createdAt && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
Reference in New Issue
Block a user