feat: live preview (#3382)

This commit is contained in:
Jacob Fletcher
2023-10-02 11:40:08 -04:00
committed by GitHub
parent ec0f5a77b7
commit a53cbd146f
79 changed files with 2173 additions and 12 deletions

View File

@@ -12,6 +12,7 @@
&__tab {
display: flex;
white-space: nowrap;
}
@include mid-break {

View File

@@ -8,6 +8,14 @@ export const tabs: DocumentTabConfig[] = [
location.pathname === href || location.pathname === `${href}/create`,
label: ({ t }) => t('edit'),
},
// Live Preview
{
condition: ({ collection, global }) =>
Boolean(collection?.admin?.livePreview || global?.admin?.livePreview),
href: ({ match }) => `${match.url}/preview`,
isActive: ({ href, location }) => location.pathname === href,
label: ({ t }) => t('livePreview'),
},
// Versions
{
condition: ({ collection, global }) => Boolean(collection?.versions || global?.versions),

View File

@@ -0,0 +1,24 @@
import React from 'react'
export const ExternalLinkIcon: React.FC<{
className?: string
}> = (props) => {
const { className } = props
return (
<svg
className={className}
clipRule="evenodd"
fillRule="evenodd"
height="100%"
viewBox="0 0 24 24"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 4h-13v18h20v-11h1v12h-22v-20h14v1zm10 5h-1v-6.293l-11.646 11.647-.708-.708 11.647-11.646h-6.293v-1h8v8z"
fill="none"
stroke="currentColor"
/>
</svg>
)
}

View File

@@ -48,6 +48,13 @@ export const GlobalRoutes: React.FC<EditViewProps> = (props) => {
<Unauthorized />
)}
</Route>
<Route
exact
key={`${global.slug}-live-preview`}
path={`${adminRoute}/globals/${global.slug}/preview`}
>
<CustomGlobalComponent view="LivePreview" {...props} />
</Route>
{globalCustomRoutes({
global,
match,

View File

@@ -0,0 +1,36 @@
import { Collision, CollisionDetection, rectIntersection } from '@dnd-kit/core'
// If the toolbar exits the preview area, we need to reset its position
// This will prevent the toolbar from getting stuck outside the preview area
export const customCollisionDetection: CollisionDetection = ({
droppableContainers,
collisionRect,
...args
}) => {
const droppableContainer = droppableContainers.find(({ id }) => id === 'live-preview-area')
const rectIntersectionCollisions = rectIntersection({
...args,
collisionRect,
droppableContainers: [droppableContainer],
})
// Collision detection algorithms return an array of collisions
if (rectIntersectionCollisions.length === 0) {
// The preview area is not intersecting, return early
return rectIntersectionCollisions
}
// Compute whether the draggable element is completely contained within the preview area
const previewAreaRect = droppableContainer?.rect?.current
const isContained =
collisionRect.top >= previewAreaRect.top &&
collisionRect.left >= previewAreaRect.left &&
collisionRect.bottom <= previewAreaRect.bottom &&
collisionRect.right <= previewAreaRect.right
if (isContained) {
return rectIntersectionCollisions
}
}

View File

@@ -0,0 +1,53 @@
import { Dispatch, createContext, useContext } from 'react'
import { LivePreview } from '../../../../../exports/config'
import { SizeReducerAction } from './sizeReducer'
export interface LivePreviewContextType {
zoom: number
setZoom: (zoom: number) => void
size: {
width: number
height: number
}
setWidth: (width: number) => void
setHeight: (height: number) => void
setSize: Dispatch<SizeReducerAction>
breakpoint: LivePreview['breakpoints'][number]['name']
iframeRef: React.RefObject<HTMLIFrameElement>
deviceFrameRef: React.RefObject<HTMLDivElement>
iframeHasLoaded: boolean
setIframeHasLoaded: (loaded: boolean) => void
toolbarPosition: {
x: number
y: number
}
setToolbarPosition: (position: { x: number; y: number }) => void
breakpoints: LivePreview['breakpoints']
setBreakpoint: (breakpoint: LivePreview['breakpoints'][number]['name']) => void
}
export const LivePreviewContext = createContext<LivePreviewContextType>({
zoom: 1,
setZoom: () => {},
size: {
width: 0,
height: 0,
},
setWidth: () => {},
setHeight: () => {},
setSize: () => {},
breakpoint: undefined,
iframeRef: undefined,
deviceFrameRef: undefined,
iframeHasLoaded: false,
setIframeHasLoaded: () => {},
toolbarPosition: {
x: 0,
y: 0,
},
setToolbarPosition: () => {},
breakpoints: undefined,
setBreakpoint: () => {},
})
export const useLivePreviewContext = () => useContext(LivePreviewContext)

View File

@@ -0,0 +1,139 @@
import { DndContext } from '@dnd-kit/core'
import React, { useCallback, useEffect } from 'react'
import type { usePopupWindow } from '../usePopupWindow'
import { EditViewProps } from '../../types'
import { LivePreviewContext } from './context'
import { customCollisionDetection } from './collisionDetection'
import { LivePreview } from '../../../../../exports/config'
import { useResize } from '../../../../utilities/useResize'
import { sizeReducer } from './sizeReducer'
export type ToolbarProviderProps = EditViewProps & {
breakpoints?: LivePreview['breakpoints']
deviceSize?: {
height: number
width: number
}
popupState: ReturnType<typeof usePopupWindow>
url?: string
children: React.ReactNode
}
export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const { children, breakpoints } = props
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false)
const [zoom, setZoom] = React.useState(1)
const [position, setPosition] = React.useState({ x: 0, y: 0 })
const [size, setSize] = React.useReducer(sizeReducer, { width: 0, height: 0 })
const [breakpoint, setBreakpoint] =
React.useState<LivePreview['breakpoints'][0]['name']>('responsive')
const foundBreakpoint = breakpoint && breakpoints.find((bp) => bp.name === breakpoint)
let margin = '0'
if (foundBreakpoint && breakpoint !== 'responsive') {
margin = '0 auto'
if (
typeof zoom === 'number' &&
typeof foundBreakpoint.width === 'number' &&
typeof foundBreakpoint.height === 'number'
) {
// keep it centered horizontally
margin = `0 ${foundBreakpoint.width / 2 / zoom}px`
}
}
let url
if ('collection' in props) {
url = props?.collection.admin.livePreview.url
}
if ('global' in props) {
url = props?.global.admin.livePreview.url
}
// The toolbar needs to freely drag and drop around the page
const handleDragEnd = (ev) => {
// only update position if the toolbar is completely within the preview area
// otherwise reset it back to the previous position
// TODO: reset to the nearest edge of the preview area
if (ev.over && ev.over.id === 'live-preview-area') {
const newPos = {
x: position.x + ev.delta.x,
y: position.y + ev.delta.y,
}
setPosition(newPos)
} else {
// reset
}
}
const setWidth = useCallback(
(width) => {
setSize({ type: 'width', value: width })
},
[setSize],
)
const setHeight = useCallback(
(height) => {
setSize({ type: 'height', value: height })
},
[setSize],
)
const { size: actualDeviceSize } = useResize(deviceFrameRef)
useEffect(() => {
if (actualDeviceSize) {
setSize({
type: 'reset',
value: {
width: Number(actualDeviceSize.width.toFixed(0)),
height: Number(actualDeviceSize.height.toFixed(0)),
},
})
}
}, [actualDeviceSize])
return (
<LivePreviewContext.Provider
value={{
zoom,
setZoom,
size,
setWidth,
setHeight,
setSize,
breakpoint,
iframeRef,
deviceFrameRef,
iframeHasLoaded,
setIframeHasLoaded,
toolbarPosition: position,
setToolbarPosition: setPosition,
breakpoints,
setBreakpoint,
}}
>
<DndContext collisionDetection={customCollisionDetection} onDragEnd={handleDragEnd}>
{children}
</DndContext>
</LivePreviewContext.Provider>
)
}

View File

@@ -0,0 +1,39 @@
// export const sizeReducer: (state, action) => {
// switch (action.type) {
// case 'width':
// return { ...state, width: action.value }
// case 'height':
// return { ...state, height: action.value }
// default:
// return { ...state, ...(action?.value || {}) }
// }
// },
type SizeReducerState = {
width: number
height: number
}
export type SizeReducerAction =
| {
type: 'width' | 'height'
value: number
}
| {
type: 'reset'
value: {
width: number
height: number
}
}
export const sizeReducer = (state: SizeReducerState, action: SizeReducerAction) => {
switch (action.type) {
case 'width':
return { ...state, width: action.value }
case 'height':
return { ...state, height: action.value }
default:
return { ...state, ...(action?.value || {}) }
}
}

View File

@@ -0,0 +1,8 @@
@import '../../../../scss/styles.scss';
.live-preview-iframe {
border: 0;
width: 100%;
height: 100%;
transform-origin: top left;
}

View File

@@ -0,0 +1,31 @@
import React, { forwardRef } from 'react'
import { useLivePreviewContext } from '../PreviewContext/context'
import './index.scss'
const baseClass = 'live-preview-iframe'
export const IFrame: React.FC<{
url: string
ref: React.Ref<HTMLIFrameElement>
setIframeHasLoaded: (value: boolean) => void
}> = forwardRef((props, ref) => {
const { url, setIframeHasLoaded } = props
const { zoom } = useLivePreviewContext()
return (
<iframe
className={baseClass}
onLoad={() => {
setIframeHasLoaded(true)
}}
ref={ref}
src={url}
title={url}
style={{
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,
}}
/>
)
})

View File

@@ -0,0 +1,26 @@
@import '../../../../scss/styles.scss';
.live-preview-window {
width: 60%;
position: sticky;
top: var(--doc-controls-height);
height: calc(100vh - var(--doc-controls-height));
overflow: hidden;
&--has-breakpoint {
padding-top: var(--base);
.live-preview-iframe {
border: 1px solid var(--theme-elevation-100);
}
}
&__wrapper {
height: 100%;
width: 100%;
}
@include mid-break {
width: 100%;
}
}

View File

@@ -0,0 +1,196 @@
import React, { useEffect } from 'react'
import type { usePopupWindow } from '../usePopupWindow'
import { useAllFormFields } from '../../../forms/Form/context'
import reduceFieldsToValues from '../../../forms/Form/reduceFieldsToValues'
import { IFrame } from '../PreviewIFrame'
import { EditViewProps } from '../../types'
import { LivePreviewProvider } from '../PreviewContext'
import { LivePreview } from '../../../../../exports/config'
import { useLivePreviewContext } from '../PreviewContext/context'
import { ToolbarArea } from '../ToolbarArea'
import { LivePreviewToolbar } from '../Toolbar'
import './index.scss'
const baseClass = 'live-preview-window'
const ResponsiveWindow: React.FC<{
children: React.ReactNode
}> = (props) => {
const { children } = props
const { breakpoint, zoom, breakpoints, deviceFrameRef } = useLivePreviewContext()
const foundBreakpoint = breakpoint && breakpoints.find((bp) => bp.name === breakpoint)
let x = '0'
if (foundBreakpoint && breakpoint !== 'responsive') {
x = '-50%'
if (
typeof zoom === 'number' &&
typeof foundBreakpoint.width === 'number' &&
typeof foundBreakpoint.height === 'number'
) {
// keep it centered horizontally
x = `${foundBreakpoint.width / 2}px`
}
}
return (
<div
ref={deviceFrameRef}
className={`${baseClass}__responsive-window`}
style={{
height:
foundBreakpoint && typeof foundBreakpoint?.height === 'number'
? `${foundBreakpoint?.height / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
width:
foundBreakpoint && typeof foundBreakpoint?.width === 'number'
? `${foundBreakpoint?.width / (typeof zoom === 'number' ? zoom : 1)}px`
: typeof zoom === 'number'
? `${100 / zoom}%`
: '100%',
transform: `translate3d(${x}, 0, 0)`,
}}
>
{children}
</div>
)
}
const Preview: React.FC<
EditViewProps & {
popupState: ReturnType<typeof usePopupWindow>
url?: string
}
> = (props) => {
const {
popupState: { isPopupOpen, popupHasLoaded, popupRef },
} = props
const { iframeRef, setIframeHasLoaded, iframeHasLoaded } = useLivePreviewContext()
let url
let breakpoints: LivePreview['breakpoints'] = [
{
name: 'responsive',
height: '100%',
label: 'Responsive',
width: '100%',
},
]
if ('collection' in props) {
url = props?.collection.admin.livePreview.url
breakpoints = breakpoints.concat(props?.collection.admin.livePreview.breakpoints)
}
if ('global' in props) {
url = props?.global.admin.livePreview.url
breakpoints = breakpoints.concat(props?.global.admin.livePreview.breakpoints)
}
const { toolbarPosition, breakpoint } = useLivePreviewContext()
const [fields] = useAllFormFields()
// The preview could either be an iframe embedded on the page
// Or it could be a separate popup window
// We need to transmit data to both accordingly
useEffect(() => {
if (fields && window && 'postMessage' in window) {
const values = reduceFieldsToValues(fields)
const message = JSON.stringify({ data: values, type: 'livePreview' })
// external window
if (isPopupOpen) {
setIframeHasLoaded(false)
if (popupHasLoaded && popupRef.current) {
popupRef.current.postMessage(message, url)
}
}
// embedded iframe
if (!isPopupOpen) {
if (iframeHasLoaded && iframeRef.current) {
iframeRef.current.contentWindow?.postMessage(message, url)
}
}
}
}, [fields, url, iframeHasLoaded, isPopupOpen, popupRef, popupHasLoaded])
if (!isPopupOpen) {
return (
<div
className={[
baseClass,
isPopupOpen && `${baseClass}--popup-open`,
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
]
.filter(Boolean)
.join(' ')}
>
<ToolbarArea>
<div className={`${baseClass}__wrapper`}>
<ResponsiveWindow>
<IFrame ref={iframeRef} url={url} setIframeHasLoaded={setIframeHasLoaded} />
</ResponsiveWindow>
</div>
<LivePreviewToolbar
{...props}
iframeRef={iframeRef}
style={{
left: `${toolbarPosition.x}px`,
top: `${toolbarPosition.y}px`,
}}
url={url}
/>
</ToolbarArea>
</div>
)
}
}
export const PreviewWindow: React.FC<
EditViewProps & {
popupState: ReturnType<typeof usePopupWindow>
url?: string
}
> = (props) => {
let url
let breakpoints: LivePreview['breakpoints'] = [
{
name: 'responsive',
height: '100%',
label: 'Responsive',
width: '100%',
},
]
if ('collection' in props) {
url = props?.collection.admin.livePreview.url
breakpoints = breakpoints.concat(props?.collection.admin.livePreview.breakpoints)
}
if ('global' in props) {
url = props?.global.admin.livePreview.url
breakpoints = breakpoints.concat(props?.global.admin.livePreview.breakpoints)
}
return (
<LivePreviewProvider {...props} breakpoints={breakpoints}>
<Preview {...props} />
</LivePreviewProvider>
)
}

View File

@@ -0,0 +1,14 @@
@import '../../../../../scss/styles.scss';
.live-preview-toolbar {
&__size {
width: 50px;
height: var(--base);
display: flex;
align-items: center;
border: 1px solid var(--theme-elevation-200);
background: var(--theme-elevation-100);
border-radius: 2px;
font-size: small;
}
}

View File

@@ -0,0 +1,54 @@
import React, { useCallback } from 'react'
import { useLivePreviewContext } from '../../PreviewContext/context'
import './index.scss'
const baseClass = 'live-preview-toolbar'
export const PreviewFrameSizeInput: React.FC<{
axis?: 'x' | 'y'
}> = (props) => {
const { axis } = props
const { setWidth, setHeight, size, deviceFrameRef } = useLivePreviewContext()
// const [size, setSize] = React.useState<string>(() => {
// if (sizeToUse === 'width') {
// return deviceSize?.width.toFixed(0)
// }
// return deviceSize?.height.toFixed(0)
// })
// useEffect(() => {
// if (sizeToUse === 'width') {
// setSize(deviceSize?.width.toFixed(0))
// } else {
// setSize(deviceSize?.height.toFixed(0))
// }
// }, [deviceSize])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (axis === 'x') {
setWidth(Number(e.target.value))
} else {
setHeight(Number(e.target.value))
}
},
[axis, setWidth, setHeight],
)
const sizeValue = axis === 'x' ? size?.width : size?.height
return (
<input
className={`${baseClass}__size`}
type="number"
value={sizeValue}
onChange={handleChange}
disabled // enable this once its wired up properly
/>
)
}

View File

@@ -0,0 +1,82 @@
@import '../../../../scss/styles.scss';
.live-preview-toolbar {
display: flex;
background-color: var(--theme-bg);
border: 1px solid var(--theme-elevation-200);
border-radius: 1px;
color: var(--theme-text);
position: absolute;
top: 0;
left: 0;
margin: 0;
&__drag-handle {
background: transparent;
border: 0;
padding: 0;
cursor: grab;
.icon--drag-handle .fill {
fill: var(--theme-elevation-300);
}
&:active {
cursor: grabbing;
}
}
&__controls {
padding: 6px 6px 6px 0;
display: flex;
align-items: center;
gap: calc(var(--base) / 3);
}
&__breakpoint {
border: none;
background: transparent;
height: var(--base);
&:focus {
outline: none;
}
}
&__device-size {
display: flex;
align-items: center;
}
&__size {
width: 50px;
height: var(--base);
display: flex;
align-items: center;
border: 1px solid var(--theme-elevation-200);
background: var(--theme-elevation-100);
border-radius: 2px;
font-size: small;
}
&__zoom {
width: 55px;
border: none;
background: transparent;
height: var(--base);
&:focus {
outline: none;
}
}
&__external {
flex-shrink: 0;
display: flex;
width: var(--base);
height: var(--base);
align-items: center;
justify-content: center;
padding: 6px 0;
}
}

View File

@@ -0,0 +1,93 @@
import { useDraggable } from '@dnd-kit/core'
import React from 'react'
import type { ToolbarProviderProps } from '../PreviewContext'
import { X } from '../../..'
import { ExternalLinkIcon } from '../../../graphics/ExternalLink'
import DragHandle from '../../../icons/Drag'
import { useLivePreviewContext } from '../PreviewContext/context'
import { PreviewFrameSizeInput } from './SizeInput'
import './index.scss'
const baseClass = 'live-preview-toolbar'
export const LivePreviewToolbar: React.FC<
Omit<ToolbarProviderProps, 'children'> & {
iframeRef: React.RefObject<HTMLIFrameElement>
style?: React.CSSProperties
}
> = (props) => {
const {
deviceSize,
popupState: { openPopupWindow },
style,
url,
} = props
const { zoom, setZoom, breakpoint, breakpoints, setBreakpoint } = useLivePreviewContext()
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: 'live-preview-toolbar',
})
return (
<div
className={baseClass}
style={{
...style,
transform: transform
? `translate3d(${transform?.x || 0}px, ${transform?.y || 0}px, 0)`
: undefined,
}}
>
<button
{...listeners}
{...attributes}
className={`${baseClass}__drag-handle`}
ref={setNodeRef}
type="button"
>
<DragHandle />
</button>
<div className={`${baseClass}__controls`}>
{breakpoints?.length > 0 && (
<select
className={`${baseClass}__breakpoint`}
onChange={(e) => setBreakpoint(e.target.value)}
value={breakpoint}
>
{breakpoints.map((bp) => (
<option key={bp.name} value={bp.name}>
{bp.label}
</option>
))}
</select>
)}
<div className={`${baseClass}__device-size`}>
<PreviewFrameSizeInput axis="x" />
<span className={`${baseClass}__size-divider`}>
<X />
</span>
<PreviewFrameSizeInput axis="y" />
</div>
<select
className={`${baseClass}__zoom`}
onChange={(e) => setZoom(Number(e.target.value) / 100)}
value={zoom * 100}
>
<option value={50}>50%</option>
<option value={75}>75%</option>
<option value={100}>100%</option>
<option value={125}>125%</option>
<option value={150}>150%</option>
<option value={200}>200%</option>
</select>
<a className={`${baseClass}__external`} href={url} onClick={openPopupWindow} type="button">
<ExternalLinkIcon />
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
@import '../../../../scss/styles.scss';
.toolbar-area {
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,22 @@
import { useDroppable } from '@dnd-kit/core'
import React from 'react'
import './index.scss'
const baseClass = 'toolbar-area'
export const ToolbarArea: React.FC<{
children: React.ReactNode
}> = (props) => {
const { children } = props
const { setNodeRef } = useDroppable({
id: 'live-preview-area',
})
return (
<div ref={setNodeRef} className={baseClass}>
{children}
</div>
)
}

View File

@@ -0,0 +1,75 @@
@import '../../../scss/styles.scss';
.live-preview {
width: 100%;
display: flex;
[dir='rtl'] & {
flex-direction: row-reverse;
}
&--popup-open {
.live-preview {
&__edit {
padding-right: var(--gutter-h);
}
}
}
&__main {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
min-height: 100%;
position: relative;
&::after {
content: ' ';
position: absolute;
top: 0;
right: 0;
width: calc(var(--base) * 2);
height: 100%;
background: linear-gradient(to left, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0) 100%);
pointer-events: none;
z-index: -1;
}
}
&__edit {
padding-top: calc(var(--base) * 1.5);
padding-bottom: base(4);
flex-grow: 1;
padding-right: calc(var(--base) * 2);
[dir='rtl'] & {
padding-right: 0;
padding-left: calc(var(--base) * 2);
}
}
@include mid-break {
flex-direction: column;
&__main {
min-height: initial;
}
&__form {
display: block;
}
&__edit {
padding-top: var(--base);
padding-bottom: 0;
padding-right: var(--gutter-h);
}
}
@include small-break {
&__edit {
padding-top: calc(var(--base) / 2);
}
}
}

View File

@@ -0,0 +1,118 @@
import React, { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from '../../../../exports/types'
import { getTranslation } from '../../../../utilities/getTranslation'
import { DocumentControls } from '../../elements/DocumentControls'
import { Gutter } from '../../elements/Gutter'
import RenderFields from '../../forms/RenderFields'
import { filterFields } from '../../forms/RenderFields/filterFields'
import { fieldTypes } from '../../forms/field-types'
import LeaveWithoutSaving from '../../modals/LeaveWithoutSaving'
import Meta from '../../utilities/Meta'
import { PreviewWindow } from './PreviewWindow'
import './index.scss'
import { usePopupWindow } from './usePopupWindow'
import { EditViewProps } from '../types'
const baseClass = 'live-preview'
export const LivePreviewView: React.FC<EditViewProps> = (props) => {
const { i18n, t } = useTranslation('general')
let url
if ('collection' in props) {
url = props?.collection.admin.livePreview.url
}
if ('global' in props) {
url = props?.global.admin.livePreview.url
}
const popupState = usePopupWindow({
eventType: 'livePreview',
href: url,
})
const { apiURL, data, permissions } = props
let collection: SanitizedCollectionConfig
let global: SanitizedGlobalConfig
let disableActions: boolean
let disableLeaveWithoutSaving: boolean
let hasSavePermission: boolean
let isEditing: boolean
let id: string
if ('collection' in props) {
collection = props?.collection
disableActions = props?.disableActions
disableLeaveWithoutSaving = props?.disableLeaveWithoutSaving
hasSavePermission = props?.hasSavePermission
isEditing = props?.isEditing
id = props?.id
}
if ('global' in props) {
global = props?.global
}
const { fields } = collection
const sidebarFields = filterFields({
fieldSchema: fields,
fieldTypes,
filter: (field) => field?.admin?.position === 'sidebar',
permissions: permissions.fields,
readOnly: !hasSavePermission,
})
return (
<Fragment>
<DocumentControls
apiURL={apiURL}
collection={collection}
data={data}
disableActions={disableActions}
global={global}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}
permissions={permissions}
/>
<div
className={[baseClass, popupState?.isPopupOpen && `${baseClass}--detached`]
.filter(Boolean)
.join(' ')}
>
<div className={`${baseClass}__main`}>
<Meta
description={t('editing')}
keywords={`${getTranslation(collection.labels.singular, i18n)}, Payload, CMS`}
title={`${isEditing ? t('editing') : t('creating')} - ${getTranslation(
collection.labels.singular,
i18n,
)}`}
/>
{!(collection.versions?.drafts && collection.versions?.drafts?.autosave) &&
!disableLeaveWithoutSaving && <LeaveWithoutSaving />}
<Gutter className={`${baseClass}__edit`}>
<RenderFields
fieldSchema={fields}
fieldTypes={fieldTypes}
filter={(field) => !field?.admin?.position || field?.admin?.position !== 'sidebar'}
permissions={permissions.fields}
readOnly={!hasSavePermission}
/>
{sidebarFields && sidebarFields.length > 0 && (
<RenderFields fieldTypes={fieldTypes} fields={sidebarFields} />
)}
</Gutter>
</div>
<PreviewWindow {...props} popupState={popupState} url={url} />
</div>
</Fragment>
)
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useState } from 'react'
// To prevent the flicker of missing data on initial load,
// you can pass in the initial page data from the server
// To prevent the flicker of stale data while the post message is being sent,
// you can conditionally render loading UI based on the `isLoading` state
export const useLivePreview = (props: {
initialPage: any
serverURL: string
}): {
data: any
isLoading: boolean
} => {
const { initialPage, serverURL } = props
const [data, setData] = useState<any>(initialPage)
const [isLoading, setIsLoading] = useState<boolean>(true)
const handleMessage = useCallback(
(event: MessageEvent) => {
if (event.origin === serverURL && event.data) {
const eventData = JSON.parse(event?.data)
if (eventData.type === 'livePreview') {
setData(eventData?.data)
setIsLoading(false)
}
}
},
[serverURL],
)
useEffect(() => {
window.addEventListener('message', handleMessage)
window.parent.postMessage('ready', serverURL)
return () => {
window.removeEventListener('message', handleMessage)
}
}, [serverURL, handleMessage])
return {
data,
isLoading,
}
}

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useConfig } from '../../utilities/Config'
export interface PopupMessage {
searchParams: {
[key: string]: string | undefined
code: string
installation_id: string
state: string
}
type: string
}
export const usePopupWindow = (props: {
eventType?: string
href: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMessage?: (searchParams: PopupMessage['searchParams']) => Promise<void>
}): {
isPopupOpen: boolean
openPopupWindow: (e: React.MouseEvent<HTMLAnchorElement>) => void
popupHasLoaded: boolean
popupRef?: React.MutableRefObject<Window | null>
} => {
const { eventType, href, onMessage } = props
const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false)
const [popupHasLoaded, setPopupHasLoaded] = useState(false)
const { serverURL } = useConfig()
const popupRef = useRef<Window | null>(null)
// Optionally broadcast messages back out to the parent component
useEffect(() => {
const receiveMessage = async (event: MessageEvent): Promise<void> => {
if (
event.origin !== window.location.origin ||
event.origin !== href ||
event.origin !== serverURL
) {
// console.warn(`Message received by ${event.origin}; IGNORED.`) // eslint-disable-line no-console
return
}
if (
typeof onMessage === 'function' &&
event.data?.type === eventType &&
!isReceivingMessage.current
) {
isReceivingMessage.current = true
await onMessage(event.data?.searchParams)
isReceivingMessage.current = false
}
}
window.addEventListener('message', receiveMessage, false)
return () => {
window.removeEventListener('message', receiveMessage)
}
}, [onMessage, eventType, href, serverURL])
// Customize the size, position, and style of the popup window
const openPopupWindow = useCallback(
(e) => {
e.preventDefault()
const features = {
height: 700,
left: 'auto',
menubar: 'no',
popup: 'yes',
toolbar: 'no',
top: 'auto',
width: 800,
}
const popupOptions = Object.entries(features)
.reduce((str, [key, value]) => {
let strCopy = str
if (value === 'auto') {
if (key === 'top') {
const v = Math.round(window.innerHeight / 2 - features.height / 2)
strCopy += `top=${v},`
} else if (key === 'left') {
const v = Math.round(window.innerWidth / 2 - features.width / 2)
strCopy += `left=${v},`
}
return strCopy
}
strCopy += `${key}=${value},`
return strCopy
}, '')
.slice(0, -1) // remove last ',' (comma)
const newWindow = window.open(href, '_blank', popupOptions)
popupRef.current = newWindow
setIsOpen(true)
},
[href],
)
// 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
// there is no way in js to add an event listener to a popup window across domains
useEffect(() => {
window.addEventListener('message', (event) => {
if (event.origin === href && event.data === 'ready') {
setPopupHasLoaded(true)
}
})
}, [href])
// 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
useEffect(() => {
let timer: NodeJS.Timeout
if (isOpen) {
timer = setInterval(function () {
if (popupRef.current.closed) {
clearInterval(timer)
setIsOpen(false)
setPopupHasLoaded(false)
}
}, 1000)
} else {
clearInterval(timer)
}
return () => {
if (timer) {
clearInterval(timer)
}
}
}, [isOpen, popupRef])
return {
isPopupOpen: isOpen,
openPopupWindow,
popupHasLoaded,
popupRef,
}
}

View File

@@ -20,7 +20,7 @@
margin-top: calc(var(--base) * 2);
}
@include mid-break {
@include large-break {
&--margin-top-large {
margin-top: var(--base);
}

View File

@@ -17,12 +17,7 @@ import { EditViewProps } from '../../../types'
const baseClass = 'collection-default-edit'
export const DefaultCollectionEdit: React.FC<
EditViewProps & {
disableLeaveWithoutSaving?: boolean
disableActions?: boolean
}
> = (props) => {
export const DefaultCollectionEdit: React.FC<EditViewProps> = (props) => {
if ('collection' in props) {
const { i18n, t } = useTranslation('general')

View File

@@ -6,6 +6,7 @@ import { QueryInspector } from '../../../RestAPI'
import VersionView from '../../../Version/Version'
import VersionsView from '../../../Versions'
import { DefaultCollectionEdit } from '../Default/index'
import { LivePreviewView } from '../../../LivePreview'
export type collectionViewType =
| 'API'
@@ -21,7 +22,7 @@ export const defaultCollectionViews: {
} = {
API: QueryInspector,
Default: DefaultCollectionEdit,
LivePreview: null,
LivePreview: LivePreviewView,
References: null,
Relationships: null,
Version: VersionView,

View File

@@ -60,6 +60,13 @@ export const CollectionRoutes: React.FC<EditViewProps> = (props) => {
<Unauthorized />
)}
</Route>
<Route
exact
key={`${collection.slug}-live-preview`}
path={`${adminRoute}/collections/${collection.slug}/:id/preview`}
>
<CustomCollectionComponent view="LivePreview" {...props} />
</Route>
{collectionCustomRoutes({
collection,
match,

View File

@@ -10,6 +10,8 @@ export type EditViewProps = (
internalState: Fields
initialState?: Fields
permissions: CollectionPermission
disableActions: boolean
disableLeaveWithoutSaving: boolean
}
| {
global: SanitizedGlobalConfig

View File

@@ -0,0 +1,68 @@
import type React from 'react'
import { useEffect, useState } from 'react'
interface Size {
height: number
width: number
}
interface Resize {
size?: Size
}
export const useResize = (ref: React.MutableRefObject<HTMLElement>): Resize => {
const [size, setSize] = useState<Size>()
useEffect(() => {
let observer: any // eslint-disable-line
const { current: currentRef } = ref
if (currentRef) {
observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const {
contentBoxSize,
contentRect, // for Safari iOS compatibility, will be deprecated eventually (see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentRect)
} = entry
let newWidth = 0
let newHeight = 0
if (contentBoxSize) {
const newSize = Array.isArray(contentBoxSize) ? contentBoxSize[0] : contentBoxSize
if (newSize) {
const { blockSize, inlineSize } = newSize
newWidth = inlineSize
newHeight = blockSize
}
} else if (contentRect) {
// see note above for why this block is needed
const { height, width } = contentRect
newWidth = width
newHeight = height
}
setSize({
height: newHeight,
width: newWidth,
})
})
})
observer.observe(currentRef)
}
return () => {
if (observer) {
observer.unobserve(currentRef)
}
}
}, [ref])
return {
size,
}
}

View File

@@ -59,6 +59,17 @@ const collectionSchema = joi.object().keys({
beforeDuplicate: joi.func(),
}),
listSearchableFields: joi.array().items(joi.string()),
livePreview: joi.object({
breakpoints: joi.array().items(
joi.object({
name: joi.string(),
height: joi.alternatives().try(joi.number(), joi.string()),
label: joi.string(),
width: joi.alternatives().try(joi.number(), joi.string()),
}),
),
url: joi.string(),
}),
pagination: joi.object({
defaultLimit: joi.number(),
limits: joi.array().items(joi.number()),

View File

@@ -19,6 +19,7 @@ import type {
Endpoint,
EntityDescription,
GeneratePreviewURL,
LivePreview,
} from '../../config/types'
import type { PayloadRequest, RequestContext } from '../../express/types'
import type { Field } from '../../fields/config/types'
@@ -269,6 +270,10 @@ export type CollectionAdminOptions = {
* Additional fields to be searched via the full text search
*/
listSearchableFields?: string[]
/**
* Live preview options
*/
livePreview?: LivePreview
pagination?: {
defaultLimit?: number
limits?: number[]

View File

@@ -38,6 +38,27 @@ type Email = {
// eslint-disable-next-line no-use-before-define
export type Plugin = (config: Config) => Config | Promise<Config>
export type LivePreview = {
/**
Device breakpoints to use for the `iframe` of the Live Preview window.
Options are displayed in the Live Preview toolbar.
The `responsive` breakpoint is included by default.
*/
breakpoints?: {
height: number | string
label: string
name: string
width: number | string
}[]
/**
The URL of the frontend application. This will be rendered within an `iframe` as its `src`.
Payload will send a `window.postMessage()` to this URL with the document data in real-time.
The frontend application is responsible for receiving the message and updating the UI accordingly.
Use the `useLivePreview` hook to get started in React applications.
*/
url?: string
}
type GeneratePreviewURLOptions = {
locale: string
token: string

View File

@@ -41,6 +41,17 @@ const globalSchema = joi
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
hidden: joi.alternatives().try(joi.boolean(), joi.func()),
hideAPIURL: joi.boolean(),
livePreview: joi.object({
breakpoints: joi.array().items(
joi.object({
name: joi.string(),
height: joi.alternatives().try(joi.number(), joi.string()),
label: joi.string(),
width: joi.alternatives().try(joi.number(), joi.string()),
}),
),
url: joi.string(),
}),
preview: joi.func(),
}),
custom: joi.object().pattern(joi.string(), joi.any()),

View File

@@ -15,6 +15,7 @@ import type {
Endpoint,
EntityDescription,
GeneratePreviewURL,
LivePreview,
} from '../../config/types'
import type { PayloadRequest } from '../../express/types'
import type { Field } from '../../fields/config/types'
@@ -119,6 +120,10 @@ export type GlobalAdminOptions = {
* Hide the API URL within the Edit view
*/
hideAPIURL?: boolean
/**
* Live preview options
*/
livePreview?: LivePreview
/**
* Function to generate custom preview URL
*/

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "المغادرة على أي حال",
"leaveWithoutSaving": "المغادرة بدون حفظ",
"light": "فاتح",
"livePreview": "معاينة مباشرة",
"loading": "يتمّ التّحميل",
"locale": "اللّغة",
"locales": "اللّغات",

View File

@@ -200,6 +200,7 @@
"leaveAnyway": "Heç olmasa çıx",
"leaveWithoutSaving": "Saxlamadan çıx",
"light": "Açıq",
"livePreview": "Öncədən baxış",
"loading": "Yüklənir",
"locale": "Lokal",
"locales": "Dillər",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Напусни въпреки това",
"leaveWithoutSaving": "Напусни без да запазиш",
"light": "Светла",
"livePreview": "Предварителен преглед",
"loading": "Зарежда се",
"locale": "Локализация",
"locales": "Локализации",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Přesto odejít",
"leaveWithoutSaving": "Odejít bez uložení",
"light": "Světlé",
"livePreview": "Náhled",
"loading": "Načítání",
"locale": "Místní verze",
"locales": "Lokality",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Trotzdem verlassen",
"leaveWithoutSaving": "Ohne speichern verlassen",
"light": "Hell",
"livePreview": "Vorschau",
"loading": "Lädt",
"locale": "Sprachumgebung",
"locales": "Sprachumgebungen",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Leave anyway",
"leaveWithoutSaving": "Leave without saving",
"light": "Light",
"livePreview": "Live Preview",
"loading": "Loading",
"locale": "Locale",
"locales": "Locales",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Salir de todos modos",
"leaveWithoutSaving": "Salir sin guardar",
"light": "Claro",
"livePreview": "Previsualizar",
"loading": "Cargando",
"locale": "Regional",
"locales": "Locales",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "به هر حال ترک کن",
"leaveWithoutSaving": "ترک کردن بدون ذخیره",
"light": "روشن",
"livePreview": "پیش‌نمایش",
"loading": "در حال بارگذاری",
"locale": "زبان",
"locales": "زبان‌ها",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Quitter quand même",
"leaveWithoutSaving": "Quitter sans sauvegarder",
"light": "Lumière ou Jour",
"livePreview": "Aperçu",
"loading": "Chargement en cours",
"locale": "Paramètres régionaux",
"locales": "Paramètres régionaux",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Svejedno napusti",
"leaveWithoutSaving": "Napusti bez spremanja",
"light": "Svijetlo",
"livePreview": "Pregled",
"loading": "Učitavanje",
"locale": "Jezik",
"locales": "Prijevodi",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Távozás mindenképp",
"leaveWithoutSaving": "Távozás mentés nélkül",
"light": "Világos",
"livePreview": "Előnézet",
"loading": "Betöltés",
"locale": "Nyelv",
"locales": "Nyelvek",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Esci comunque",
"leaveWithoutSaving": "Esci senza salvare",
"light": "Chiaro",
"preview": "Anteprima",
"loading": "Caricamento",
"locale": "Locale",
"locales": "Localizzazioni",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "すぐに画面を離れる",
"leaveWithoutSaving": "内容が保存されていません",
"light": "ライトモード",
"livePreview": "プレビュー",
"loading": "ローディング中",
"locale": "ロケール",
"locales": "ロケール",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "ဘာဖြစ်ဖြစ် ထွက်မည်။",
"leaveWithoutSaving": "မသိမ်းဘဲ ထွက်မည်။",
"light": "အလင်း",
"livePreview": "အစမ်းကြည့်ရန်",
"loading": "ဖွင့်နေသည်",
"locale": "ဒေသ",
"locales": "Locales",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Forlat likevel",
"leaveWithoutSaving": "Forlat uten å lagre",
"light": "Lys",
"livePreview": "Forhåndsvisning",
"loading": "Laster",
"locale": "Lokalitet",
"locales": "Språk",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Toch weggaan",
"leaveWithoutSaving": "Verlaten zonder op te slaan",
"light": "Licht",
"livePreview": "Voorbeeld",
"loading": "Laden",
"locale": "Taal",
"locales": "Landinstellingen",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Wyjdź mimo to",
"leaveWithoutSaving": "Wyjdź bez zapisywania",
"light": "Jasny",
"livePreview": "Podgląd",
"loading": "Ładowanie",
"locale": "Lokalizacja",
"locales": "Lokalne",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Sair mesmo assim",
"leaveWithoutSaving": "Sair sem salvar",
"light": "Claro",
"livePreview": "Pré-visualização",
"loading": "Carregando",
"locale": "Local",
"locales": "Localizações",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Pleacă oricum",
"leaveWithoutSaving": "Plecare fără a salva",
"light": "Light",
"livePreview": "Previzualizare",
"loading": "Încărcare",
"locale": "Localitate",
"locales": "Localuri",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Все равно уйти",
"leaveWithoutSaving": "Выход без сохранения",
"light": "Светлая",
"livePreview": "Предпросмотр",
"loading": "Загрузка",
"locale": "Локаль",
"locales": "Локали",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Lämna ändå",
"leaveWithoutSaving": "Lämna utan att spara",
"light": "Ljus",
"livePreview": "Förhandsvisa",
"loading": "Läser in",
"locale": "Lokal",
"locales": "Språk",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "ออกจากหน้านี้",
"leaveWithoutSaving": "ออกโดยไม่บันทึก",
"light": "สว่าง",
"livePreview": "แสดงตัวอย่าง",
"loading": "กำลังโหลด",
"locale": "ตำแหน่งที่ตั้ง",
"locales": "ภาษา",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Yine de ayrıl",
"leaveWithoutSaving": "Kaydetmeden ayrıl",
"light": "Aydınlık",
"livePreview": "Önizleme",
"loading": "Yükleniyor",
"locale": "Yerel ayar",
"locales": "Diller",

View File

@@ -739,6 +739,9 @@
"light": {
"type": "string"
},
"livePreview": {
"type": "string"
},
"loading": {
"type": "string"
},

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Все одно вийти",
"leaveWithoutSaving": "Вийти без збереження",
"light": "Світла",
"livePreview": "Попередній перегляд",
"loading": "Загрузка",
"locale": "Локаль",
"locales": "Переклади",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "Tiếp tục thoát",
"leaveWithoutSaving": "Thay đổi chưa được lưu",
"light": "Nền sáng",
"livePreview": "Xem trước",
"loading": "Đang tải",
"locale": "Ngôn ngữ",
"locales": "Khu vực",

View File

@@ -199,6 +199,7 @@
"leaveAnyway": "无论如何都要离开",
"leaveWithoutSaving": "离开而不保存",
"light": "亮色",
"livePreview": "预览",
"loading": "加载中...",
"locale": "语言环境",
"locales": "语言环境",

View File

@@ -5,6 +5,7 @@ import path from 'path'
import { v4 as uuid } from 'uuid'
import payload from '../packages/payload/src'
import { startLivePreviewDemo } from './live-preview/startLivePreviewDemo'
dotenv.config()
@@ -60,6 +61,12 @@ const startDev = async () => {
externalRouter.use(payload.authenticate)
if (testSuiteDir === 'live-preview') {
await startLivePreviewDemo({
payload,
})
}
expressApp.listen(3000, async () => {
payload.logger.info(`Admin URL on http://localhost:3000${payload.getAdminURL()}`)
payload.logger.info(`API URL on http://localhost:3000${payload.getAPIURL()}`)

View File

@@ -8,14 +8,16 @@ import { v4 as uuid } from 'uuid'
import type { CollectionConfig } from '../../packages/payload/src/collections/config/types'
import type { InitOptions } from '../../packages/payload/src/config/types'
import payload from '../../packages/payload/src'
import payload, { Payload } from '../../packages/payload/src'
type Options = {
__dirname: string
init?: Partial<InitOptions>
}
export async function initPayloadE2E(__dirname: string): Promise<{ serverURL: string }> {
type InitializedPayload = { serverURL: string; payload: Payload }
export async function initPayloadE2E(__dirname: string): Promise<InitializedPayload> {
const webpackCachePath = path.resolve(__dirname, '../../node_modules/.cache/webpack')
shelljs.rm('-rf', webpackCachePath)
return initPayloadTest({
@@ -26,7 +28,7 @@ export async function initPayloadE2E(__dirname: string): Promise<{ serverURL: st
})
}
export async function initPayloadTest(options: Options): Promise<{ serverURL: string }> {
export async function initPayloadTest(options: Options): Promise<InitializedPayload> {
const initOptions = {
local: true,
secret: uuid(),
@@ -65,7 +67,7 @@ export async function initPayloadTest(options: Options): Promise<{ serverURL: st
initOptions.express.listen(port)
}
return { serverURL: `http://localhost:${port}` }
return { serverURL: `http://localhost:${port}`, payload }
}
export const openAccess: CollectionConfig['access'] = {

View File

@@ -0,0 +1,90 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import { devUser } from '../credentials'
export interface Post {
createdAt: Date
description: string
id: string
title: string
updatedAt: Date
}
export const slug = 'pages'
export default buildConfigWithDefaults({
admin: {},
cors: ['http://localhost:3001'],
csrf: ['http://localhost:3001'],
collections: [
{
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [],
},
{
slug,
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
livePreview: {
url: 'http://localhost:3001',
breakpoints: [
{
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
},
// {
// label: 'Desktop',
// name: 'desktop',
// width: 1440,
// height: 900,
// },
],
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
},
],
},
],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: slug,
data: {
title: 'Hello, world!',
description: 'This is an example of live preview.',
slug: 'home',
},
})
},
})

View File

@@ -0,0 +1,31 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { startLivePreviewDemo } from './startLivePreviewDemo'
const { beforeAll, describe } = test
let url: AdminUrlUtil
describe('Live Preview', () => {
let page: Page
beforeAll(async ({ browser }) => {
const { serverURL, payload } = await initPayloadE2E(__dirname)
url = new AdminUrlUtil(serverURL, 'posts')
const context = await browser.newContext()
page = await context.newPage()
await startLivePreviewDemo({
payload,
})
})
test('example test', async () => {
await page.goto(url.list)
await expect(true).toBe(true)
})
})

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

35
test/live-preview/next-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -0,0 +1,22 @@
export type PageType = {
title?: string
description?: string
}
export const PAYLOAD_SERVER_URL = 'http://localhost:3000'
export const getPage = async (slug: string): Promise<PageType> => {
return await fetch(`http://localhost:3000/api/pages?where[slug][equals]=${slug}`, {
method: 'GET',
cache: 'no-store',
})
.then((res) => {
if (!res.ok) {
console.error(`Error fetching page: ${res.status} ${res.statusText}`)
return null
}
return res?.json()
})
?.then((res) => res?.docs?.[0])
}

View File

@@ -0,0 +1,49 @@
:root {
--max-width: 1100px;
--foreground-rgb: 0, 0, 0;
--background-rbg: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rbg: 0, 0, 0;
}
}
* {
box-sizing: border-box;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
margin: 0;
padding: 0;
}
body {
color: rgb(var(--foreground-rgb));
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Ubuntu,
'Helvetica Neue',
sans-serif;
background: rgb(var(--background-rbg));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -0,0 +1,15 @@
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Payload Live Preview',
description: 'Payload Live Preview',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import React, { Fragment } from 'react'
import styles from './page.module.css'
import { PAYLOAD_SERVER_URL, PageType } from './api'
// The `useLivePreview` hook is imported from the monorepo for development purposes only
// in your own app you would import this hook directly from the payload package itself
// i.e. `import { useLivePreview } from 'payload'`
import { useLivePreview } from '../../../../packages/payload/src/admin/components/views/LivePreview/useLivePreview'
export type Props = {
initialPage: PageType
}
export const Page: React.FC<Props> = (props) => {
const { initialPage } = props
const { data, isLoading } = useLivePreview({ initialPage, serverURL: PAYLOAD_SERVER_URL })
return (
<main className={styles.main}>
{isLoading && <Fragment>Loading...</Fragment>}
{!isLoading && (
<Fragment>
<h1>{data?.title}</h1>
<p>{data?.description}</p>
</Fragment>
)}
</main>
)
}

View File

@@ -0,0 +1,12 @@
.main {
padding: 6rem;
min-height: 100vh;
}
.main > *:first-child {
margin-top: 0;
}
.main > *:last-child {
margin-bottom: 0;
}

View File

@@ -0,0 +1,13 @@
import { getPage } from './api'
import { Page } from './page.client'
import { notFound } from 'next/navigation'
export default async function Home() {
const page = await getPage('home')
if (!page) {
notFound()
}
return <Page initialPage={page} />
}

View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// this is only required for local development of the `useLivePreview` hook
// see `./app/page.client.tsx` for more details
experimental: {
externalDir: true,
},
}
module.exports = nextConfig

View File

@@ -0,0 +1,18 @@
{
"name": "payload-live-preview",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^13.5.3"
},
"devDependencies": {
"@types/node": "20.6.2",
"@types/react": "18.2.22"
}
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,194 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@next/env@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.3.tgz#402da9a0af87f93d853519f0c2a602b1ab637c2c"
integrity sha512-X4te86vsbjsB7iO4usY9jLPtZ827Mbx+WcwNBGUOIuswuTAKQtzsuoxc/6KLxCMvogKG795MhrR1LDhYgDvasg==
"@next/swc-darwin-arm64@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.3.tgz#f72eac8c7b71d33e0768bd3c8baf68b00fea0160"
integrity sha512-6hiYNJxJmyYvvKGrVThzo4nTcqvqUTA/JvKim7Auaj33NexDqSNwN5YrrQu+QhZJCIpv2tULSHt+lf+rUflLSw==
"@next/swc-darwin-x64@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.3.tgz#96eda3a1247a713579eb241d76d3f503291c8938"
integrity sha512-UpBKxu2ob9scbpJyEq/xPgpdrgBgN3aLYlxyGqlYX5/KnwpJpFuIHU2lx8upQQ7L+MEmz+fA1XSgesoK92ppwQ==
"@next/swc-linux-arm64-gnu@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.3.tgz#132e155a029310fffcdfd3e3c4255f7ce9fd2714"
integrity sha512-5AzM7Yx1Ky+oLY6pHs7tjONTF22JirDPd5Jw/3/NazJ73uGB05NqhGhB4SbeCchg7SlVYVBeRMrMSZwJwq/xoA==
"@next/swc-linux-arm64-musl@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.3.tgz#981d7d8fdcf040bd0c89588ef4139c28805f5cf1"
integrity sha512-A/C1shbyUhj7wRtokmn73eBksjTM7fFQoY2v/0rTM5wehpkjQRLOXI8WJsag2uLhnZ4ii5OzR1rFPwoD9cvOgA==
"@next/swc-linux-x64-gnu@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.3.tgz#b8263663acda7b84bc2c4ffa39ca4b0172a78060"
integrity sha512-FubPuw/Boz8tKkk+5eOuDHOpk36F80rbgxlx4+xty/U71e3wZZxVYHfZXmf0IRToBn1Crb8WvLM9OYj/Ur815g==
"@next/swc-linux-x64-musl@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.3.tgz#cd0bed8ee92032c25090bed9d95602ac698d925f"
integrity sha512-DPw8nFuM1uEpbX47tM3wiXIR0Qa+atSzs9Q3peY1urkhofx44o7E1svnq+a5Q0r8lAcssLrwiM+OyJJgV/oj7g==
"@next/swc-win32-arm64-msvc@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.3.tgz#7f556674ca97e6936220d10c58252cc36522d80a"
integrity sha512-zBPSP8cHL51Gub/YV8UUePW7AVGukp2D8JU93IHbVDu2qmhFAn9LWXiOOLKplZQKxnIPUkJTQAJDCWBWU4UWUA==
"@next/swc-win32-ia32-msvc@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.3.tgz#4912721fb8695f11daec4cde42e73dc57bcc479f"
integrity sha512-ONcL/lYyGUj4W37D4I2I450SZtSenmFAvapkJQNIJhrPMhzDU/AdfLkW98NvH1D2+7FXwe7yclf3+B7v28uzBQ==
"@next/swc-win32-x64-msvc@13.5.3":
version "13.5.3"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.3.tgz#97340a709febb60ff73003566b99d127d4e5b881"
integrity sha512-2Vz2tYWaLqJvLcWbbTlJ5k9AN6JD7a5CN2pAeIzpbecK8ZF/yobA39cXtv6e+Z8c5UJuVOmaTldEAIxvsIux/Q==
"@swc/helpers@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"
integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==
dependencies:
tslib "^2.4.0"
"@types/node@20.6.2":
version "20.6.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.2.tgz#a065925409f59657022e9063275cd0b9bd7e1b12"
integrity sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==
"@types/prop-types@*":
version "15.7.8"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3"
integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==
"@types/react@18.2.22":
version "18.2.22"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.22.tgz#abe778a1c95a07fa70df40a52d7300a40b949ccb"
integrity sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.4"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
integrity sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==
busboy@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
caniuse-lite@^1.0.30001406:
version "1.0.30001542"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001542.tgz#823ddb5aed0a70d5e2bfb49126478e84e9514b85"
integrity sha512-UrtAXVcj1mvPBFQ4sKd38daP8dEcXXr5sQe6QNNinaPd0iA/cxg9/l3VrSdL73jgw5sKyuQ6jNgiKO12W3SsVA==
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
csstype@^3.0.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
glob-to-regexp@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
graceful-fs@^4.1.2:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
nanoid@^3.3.4:
version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
next@^13.5.3:
version "13.5.3"
resolved "https://registry.yarnpkg.com/next/-/next-13.5.3.tgz#631efcbcc9d756c610855d9b94f3d8c4e73ee131"
integrity sha512-4Nt4HRLYDW/yRpJ/QR2t1v63UOMS55A38dnWv3UDOWGezuY0ZyFO1ABNbD7mulVzs9qVhgy2+ppjdsANpKP1mg==
dependencies:
"@next/env" "13.5.3"
"@swc/helpers" "0.5.2"
busboy "1.6.0"
caniuse-lite "^1.0.30001406"
postcss "8.4.14"
styled-jsx "5.1.1"
watchpack "2.4.0"
zod "3.21.4"
optionalDependencies:
"@next/swc-darwin-arm64" "13.5.3"
"@next/swc-darwin-x64" "13.5.3"
"@next/swc-linux-arm64-gnu" "13.5.3"
"@next/swc-linux-arm64-musl" "13.5.3"
"@next/swc-linux-x64-gnu" "13.5.3"
"@next/swc-linux-x64-musl" "13.5.3"
"@next/swc-win32-arm64-msvc" "13.5.3"
"@next/swc-win32-ia32-msvc" "13.5.3"
"@next/swc-win32-x64-msvc" "13.5.3"
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
postcss@8.4.14:
version "8.4.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
source-map-js "^1.0.2"
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
styled-jsx@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f"
integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==
dependencies:
client-only "0.0.1"
tslib@^2.4.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
watchpack@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
zod@3.21.4:
version "3.21.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==

View File

@@ -0,0 +1,140 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User
'hidden-collection': HiddenCollection
posts: Post
'group-one-collection-ones': GroupOneCollectionOne
'group-one-collection-twos': GroupOneCollectionTwo
'group-two-collection-ones': GroupTwoCollectionOne
'group-two-collection-twos': GroupTwoCollectionTwo
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
globals: {
'hidden-global': HiddenGlobal
global: Global
'group-globals-one': GroupGlobalsOne
'group-globals-two': GroupGlobalsTwo
}
}
export interface User {
id: string
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string
resetPasswordExpiration?: string
salt?: string
hash?: string
loginAttempts?: number
lockUntil?: string
password?: string
}
export interface HiddenCollection {
id: string
title?: string
updatedAt: string
createdAt: string
}
export interface Post {
id: string
title?: string
description?: string
number?: number
richText?: {
[k: string]: unknown
}[]
updatedAt: string
createdAt: string
}
export interface GroupOneCollectionOne {
id: string
title?: string
updatedAt: string
createdAt: string
}
export interface GroupOneCollectionTwo {
id: string
title?: string
updatedAt: string
createdAt: string
}
export interface GroupTwoCollectionOne {
id: string
title?: string
updatedAt: string
createdAt: string
}
export interface GroupTwoCollectionTwo {
id: string
title?: string
updatedAt: string
createdAt: string
}
export interface PayloadPreference {
id: string
user: {
value: string | User
relationTo: 'users'
}
key?: string
value?:
| {
[k: string]: unknown
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
}
export interface PayloadMigration {
id: string
name?: string
batch?: number
schema?:
| {
[k: string]: unknown
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
}
export interface HiddenGlobal {
id: string
title?: string
updatedAt?: string
createdAt?: string
}
export interface Global {
id: string
title?: string
updatedAt?: string
createdAt?: string
}
export interface GroupGlobalsOne {
id: string
title?: string
updatedAt?: string
createdAt?: string
}
export interface GroupGlobalsTwo {
id: string
title?: string
updatedAt?: string
createdAt?: string
}

View File

@@ -0,0 +1,52 @@
import payload, { Payload } from '../../packages/payload/src'
import { spawn } from 'child_process'
import path from 'path'
export const startLivePreviewDemo = async (args: { payload: Payload }): Promise<void> => {
let installing = false
let started = false
// Install the node modules for the Next.js app
const installation = spawn('yarn', ['install'], {
cwd: path.resolve(__dirname, './next-app'),
})
installation.stdout.on('data', (data) => {
if (!installing) {
payload.logger.info('Installing Next.js...')
installing = true
}
payload.logger.info(data.toString())
})
installation.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
installation.on('exit', (code) => {
payload.logger.info(`Next.js exited with code ${code}`)
})
// Boot up the Next.js app
const app = spawn('yarn', ['dev'], {
cwd: path.resolve(__dirname, './next-app'),
})
app.stdout.on('data', (data) => {
if (!started) {
payload.logger.info('Starting Next.js...')
started = true
}
payload.logger.info(data.toString())
})
app.stderr.on('data', (data) => {
payload.logger.error(data.toString())
})
app.on('exit', (code) => {
payload.logger.info(`Next.js exited with code ${code}`)
})
}