feat: live preview (#3382)
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
&__tab {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 || {}) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.live-preview-iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: top left;
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.toolbar-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
margin-top: calc(var(--base) * 2);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
@include large-break {
|
||||
&--margin-top-large {
|
||||
margin-top: var(--base);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,8 @@ export type EditViewProps = (
|
||||
internalState: Fields
|
||||
initialState?: Fields
|
||||
permissions: CollectionPermission
|
||||
disableActions: boolean
|
||||
disableLeaveWithoutSaving: boolean
|
||||
}
|
||||
| {
|
||||
global: SanitizedGlobalConfig
|
||||
|
||||
68
packages/payload/src/admin/utilities/useResize.ts
Normal file
68
packages/payload/src/admin/utilities/useResize.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "المغادرة على أي حال",
|
||||
"leaveWithoutSaving": "المغادرة بدون حفظ",
|
||||
"light": "فاتح",
|
||||
"livePreview": "معاينة مباشرة",
|
||||
"loading": "يتمّ التّحميل",
|
||||
"locale": "اللّغة",
|
||||
"locales": "اللّغات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Напусни въпреки това",
|
||||
"leaveWithoutSaving": "Напусни без да запазиш",
|
||||
"light": "Светла",
|
||||
"livePreview": "Предварителен преглед",
|
||||
"loading": "Зарежда се",
|
||||
"locale": "Локализация",
|
||||
"locales": "Локализации",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Trotzdem verlassen",
|
||||
"leaveWithoutSaving": "Ohne speichern verlassen",
|
||||
"light": "Hell",
|
||||
"livePreview": "Vorschau",
|
||||
"loading": "Lädt",
|
||||
"locale": "Sprachumgebung",
|
||||
"locales": "Sprachumgebungen",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Leave anyway",
|
||||
"leaveWithoutSaving": "Leave without saving",
|
||||
"light": "Light",
|
||||
"livePreview": "Live Preview",
|
||||
"loading": "Loading",
|
||||
"locale": "Locale",
|
||||
"locales": "Locales",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Salir de todos modos",
|
||||
"leaveWithoutSaving": "Salir sin guardar",
|
||||
"light": "Claro",
|
||||
"livePreview": "Previsualizar",
|
||||
"loading": "Cargando",
|
||||
"locale": "Regional",
|
||||
"locales": "Locales",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "به هر حال ترک کن",
|
||||
"leaveWithoutSaving": "ترک کردن بدون ذخیره",
|
||||
"light": "روشن",
|
||||
"livePreview": "پیشنمایش",
|
||||
"loading": "در حال بارگذاری",
|
||||
"locale": "زبان",
|
||||
"locales": "زبانها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Svejedno napusti",
|
||||
"leaveWithoutSaving": "Napusti bez spremanja",
|
||||
"light": "Svijetlo",
|
||||
"livePreview": "Pregled",
|
||||
"loading": "Učitavanje",
|
||||
"locale": "Jezik",
|
||||
"locales": "Prijevodi",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Esci comunque",
|
||||
"leaveWithoutSaving": "Esci senza salvare",
|
||||
"light": "Chiaro",
|
||||
"preview": "Anteprima",
|
||||
"loading": "Caricamento",
|
||||
"locale": "Locale",
|
||||
"locales": "Localizzazioni",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "すぐに画面を離れる",
|
||||
"leaveWithoutSaving": "内容が保存されていません",
|
||||
"light": "ライトモード",
|
||||
"livePreview": "プレビュー",
|
||||
"loading": "ローディング中",
|
||||
"locale": "ロケール",
|
||||
"locales": "ロケール",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "ဘာဖြစ်ဖြစ် ထွက်မည်။",
|
||||
"leaveWithoutSaving": "မသိမ်းဘဲ ထွက်မည်။",
|
||||
"light": "အလင်း",
|
||||
"livePreview": "အစမ်းကြည့်ရန်",
|
||||
"loading": "ဖွင့်နေသည်",
|
||||
"locale": "ဒေသ",
|
||||
"locales": "Locales",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Forlat likevel",
|
||||
"leaveWithoutSaving": "Forlat uten å lagre",
|
||||
"light": "Lys",
|
||||
"livePreview": "Forhåndsvisning",
|
||||
"loading": "Laster",
|
||||
"locale": "Lokalitet",
|
||||
"locales": "Språk",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Toch weggaan",
|
||||
"leaveWithoutSaving": "Verlaten zonder op te slaan",
|
||||
"light": "Licht",
|
||||
"livePreview": "Voorbeeld",
|
||||
"loading": "Laden",
|
||||
"locale": "Taal",
|
||||
"locales": "Landinstellingen",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Wyjdź mimo to",
|
||||
"leaveWithoutSaving": "Wyjdź bez zapisywania",
|
||||
"light": "Jasny",
|
||||
"livePreview": "Podgląd",
|
||||
"loading": "Ładowanie",
|
||||
"locale": "Lokalizacja",
|
||||
"locales": "Lokalne",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Pleacă oricum",
|
||||
"leaveWithoutSaving": "Plecare fără a salva",
|
||||
"light": "Light",
|
||||
"livePreview": "Previzualizare",
|
||||
"loading": "Încărcare",
|
||||
"locale": "Localitate",
|
||||
"locales": "Localuri",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Все равно уйти",
|
||||
"leaveWithoutSaving": "Выход без сохранения",
|
||||
"light": "Светлая",
|
||||
"livePreview": "Предпросмотр",
|
||||
"loading": "Загрузка",
|
||||
"locale": "Локаль",
|
||||
"locales": "Локали",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "ออกจากหน้านี้",
|
||||
"leaveWithoutSaving": "ออกโดยไม่บันทึก",
|
||||
"light": "สว่าง",
|
||||
"livePreview": "แสดงตัวอย่าง",
|
||||
"loading": "กำลังโหลด",
|
||||
"locale": "ตำแหน่งที่ตั้ง",
|
||||
"locales": "ภาษา",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -739,6 +739,9 @@
|
||||
"light": {
|
||||
"type": "string"
|
||||
},
|
||||
"livePreview": {
|
||||
"type": "string"
|
||||
},
|
||||
"loading": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "Все одно вийти",
|
||||
"leaveWithoutSaving": "Вийти без збереження",
|
||||
"light": "Світла",
|
||||
"livePreview": "Попередній перегляд",
|
||||
"loading": "Загрузка",
|
||||
"locale": "Локаль",
|
||||
"locales": "Переклади",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"leaveAnyway": "无论如何都要离开",
|
||||
"leaveWithoutSaving": "离开而不保存",
|
||||
"light": "亮色",
|
||||
"livePreview": "预览",
|
||||
"loading": "加载中...",
|
||||
"locale": "语言环境",
|
||||
"locales": "语言环境",
|
||||
|
||||
@@ -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()}`)
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
90
test/live-preview/config.ts
Normal file
90
test/live-preview/config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
31
test/live-preview/e2e.spec.ts
Normal file
31
test/live-preview/e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
3
test/live-preview/next-app/.eslintrc.json
Normal file
3
test/live-preview/next-app/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
35
test/live-preview/next-app/.gitignore
vendored
Normal file
35
test/live-preview/next-app/.gitignore
vendored
Normal 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
|
||||
36
test/live-preview/next-app/README.md
Normal file
36
test/live-preview/next-app/README.md
Normal 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.
|
||||
22
test/live-preview/next-app/app/api.ts
Normal file
22
test/live-preview/next-app/app/api.ts
Normal 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])
|
||||
}
|
||||
49
test/live-preview/next-app/app/globals.css
Normal file
49
test/live-preview/next-app/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
15
test/live-preview/next-app/app/layout.tsx
Normal file
15
test/live-preview/next-app/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
test/live-preview/next-app/app/page.client.tsx
Normal file
30
test/live-preview/next-app/app/page.client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
test/live-preview/next-app/app/page.module.css
Normal file
12
test/live-preview/next-app/app/page.module.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.main {
|
||||
padding: 6rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.main > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
13
test/live-preview/next-app/app/page.tsx
Normal file
13
test/live-preview/next-app/app/page.tsx
Normal 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} />
|
||||
}
|
||||
10
test/live-preview/next-app/next.config.js
Normal file
10
test/live-preview/next-app/next.config.js
Normal 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
|
||||
18
test/live-preview/next-app/package.json
Normal file
18
test/live-preview/next-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
test/live-preview/next-app/tsconfig.json
Normal file
27
test/live-preview/next-app/tsconfig.json
Normal 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"]
|
||||
}
|
||||
194
test/live-preview/next-app/yarn.lock
Normal file
194
test/live-preview/next-app/yarn.lock
Normal 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==
|
||||
140
test/live-preview/payload-types.ts
Normal file
140
test/live-preview/payload-types.ts
Normal 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
|
||||
}
|
||||
52
test/live-preview/startLivePreviewDemo.ts
Normal file
52
test/live-preview/startLivePreviewDemo.ts
Normal 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}`)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user