chore(examples): updates preview example (#3099)
This commit is contained in:
3
examples/draft-preview/next-app/.env.example
Normal file
3
examples/draft-preview/next-app/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_CMS_URL=http://localhost:3000
|
||||
NEXT_PRIVATE_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
|
||||
NEXT_PRIVATE_REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
|
||||
7
examples/draft-preview/next-app/.eslintrc.js
Normal file
7
examples/draft-preview/next-app/.eslintrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['plugin:@next/next/recommended', '@payloadcms'],
|
||||
rules: {
|
||||
'import/extensions': 'off',
|
||||
},
|
||||
}
|
||||
6
examples/draft-preview/next-app/.gitignore
vendored
Normal file
6
examples/draft-preview/next-app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
.env
|
||||
package-lock.json
|
||||
8
examples/draft-preview/next-app/.prettierrc.js
Normal file
8
examples/draft-preview/next-app/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
37
examples/draft-preview/next-app/README.md
Normal file
37
examples/draft-preview/next-app/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Payload Draft Preview Example Front-End
|
||||
|
||||
This is a [Next.js](https://nextjs.org) app using the [App Router](https://nextjs.org/docs/app). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview).
|
||||
|
||||
> This example uses the App Router, the latest API of Next.js. If your app is using the legacy [Pages Router](https://nextjs.org/docs/pages), check out the official [Pages Router Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/next-pages).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Payload
|
||||
|
||||
First you'll need a running Payload app. There is one made explicitly for this example and [can be found here](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload). If you have not done so already, clone it down and follow the setup instructions there. This will provide all the necessary APIs that your Next.js app requires for authentication.
|
||||
|
||||
### Next.js
|
||||
|
||||
1. Clone this repo
|
||||
2. `cd` into this directory and run `yarn` or `npm install`
|
||||
3. `cp .env.example .env` to copy the example environment variables
|
||||
4. `yarn dev` or `npm run dev` to start the server
|
||||
5. `open http://localhost:3001` to see the result
|
||||
|
||||
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload) for full details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Payload and Next.js, take a look at the following resources:
|
||||
|
||||
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
|
||||
- [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 Payload GitHub repository](https://github.com/payloadcms/payload) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deployment
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. You could also combine this app into a [single Express server](https://github.com/payloadcms/payload/tree/master/examples/custom-server) and deploy in to [Payload Cloud](https://payloadcms.com/new/import).
|
||||
|
||||
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
@@ -0,0 +1,3 @@
|
||||
.page {
|
||||
margin-top: calc(var(--base) * 2);
|
||||
}
|
||||
47
examples/draft-preview/next-app/app/[slug]/page.tsx
Normal file
47
examples/draft-preview/next-app/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { draftMode } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { Page } from '../../payload-types'
|
||||
import { fetchPage } from '../_api/fetchPage'
|
||||
import { fetchPages } from '../_api/fetchPages'
|
||||
import { Gutter } from '../_components/Gutter'
|
||||
import RichText from '../_components/RichText'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
interface PageParams {
|
||||
params: { slug: string }
|
||||
}
|
||||
|
||||
export const PageTemplate: React.FC<{ page: Page | null | undefined }> = ({ page }) => (
|
||||
<main className={classes.page}>
|
||||
<Gutter>
|
||||
<h1>{page?.title}</h1>
|
||||
<RichText content={page?.richText} />
|
||||
</Gutter>
|
||||
</main>
|
||||
)
|
||||
|
||||
export default async function Page({ params: { slug = 'home' } }: PageParams) {
|
||||
const { isEnabled: isDraftMode } = draftMode()
|
||||
|
||||
const page = await fetchPage(slug, isDraftMode)
|
||||
|
||||
if (page === null) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return <PageTemplate page={page} />
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const pages = await fetchPages()
|
||||
|
||||
return pages.map(({ slug }) =>
|
||||
slug !== 'home'
|
||||
? {
|
||||
slug,
|
||||
}
|
||||
: {},
|
||||
) // eslint-disable-line function-paren-newline
|
||||
}
|
||||
34
examples/draft-preview/next-app/app/_api/fetchPage.ts
Normal file
34
examples/draft-preview/next-app/app/_api/fetchPage.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
|
||||
|
||||
import type { Page } from '../../payload-types'
|
||||
|
||||
export const fetchPage = async (
|
||||
slug: string,
|
||||
draft?: boolean,
|
||||
): Promise<Page | undefined | null> => {
|
||||
let payloadToken: RequestCookie | undefined
|
||||
|
||||
if (draft) {
|
||||
const { cookies } = await import('next/headers')
|
||||
payloadToken = cookies().get('payload-token')
|
||||
}
|
||||
|
||||
const pageRes: {
|
||||
docs: Page[]
|
||||
} = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages?where[slug][equals]=${slug}${
|
||||
draft && payloadToken ? '&draft=true' : ''
|
||||
}`,
|
||||
{
|
||||
...(draft && payloadToken
|
||||
? {
|
||||
headers: {
|
||||
Authorization: `JWT ${payloadToken?.value}`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
).then(res => res.json())
|
||||
|
||||
return pageRes?.docs?.[0] ?? null
|
||||
}
|
||||
11
examples/draft-preview/next-app/app/_api/fetchPages.ts
Normal file
11
examples/draft-preview/next-app/app/_api/fetchPages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Page } from '../../payload-types'
|
||||
|
||||
export const fetchPages = async (): Promise<Page[]> => {
|
||||
const pageRes: {
|
||||
docs: Page[]
|
||||
} = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages?depth=0&limit=100`).then(res =>
|
||||
res.json(),
|
||||
) // eslint-disable-line function-paren-newline
|
||||
|
||||
return pageRes?.docs ?? []
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { PayloadAdminBar, PayloadAdminBarProps, PayloadMeUser } from 'payload-admin-bar'
|
||||
|
||||
import { Gutter } from '../Gutter'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
const Title: React.FC = () => <span>Dashboard</span>
|
||||
|
||||
export const AdminBarClient: React.FC<PayloadAdminBarProps> = props => {
|
||||
const [user, setUser] = useState<PayloadMeUser>()
|
||||
|
||||
return (
|
||||
<div className={[classes.adminBar, user && classes.show].filter(Boolean).join(' ')}>
|
||||
<Gutter className={classes.container}>
|
||||
<PayloadAdminBar
|
||||
{...props}
|
||||
logo={<Title />}
|
||||
cmsURL={process.env.NEXT_PUBLIC_CMS_URL}
|
||||
onPreviewExit={async () => {
|
||||
await fetch(`/api/exit-preview`)
|
||||
window.location.reload()
|
||||
}}
|
||||
onAuthChange={setUser}
|
||||
className={classes.payloadAdminBar}
|
||||
classNames={{
|
||||
user: classes.user,
|
||||
logo: classes.logo,
|
||||
controls: classes.controls,
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 'unset',
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</Gutter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.adminBar {
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
background-color: rgba(var(--foreground-rgb), 0.075);
|
||||
padding: calc(var(--base) * 0.5) 0;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms linear;
|
||||
}
|
||||
|
||||
.payloadAdminBar {
|
||||
color: rgb(var(--foreground-rgb)) !important;
|
||||
}
|
||||
|
||||
.show {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
& > *:not(:last-child) {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
|
||||
.innerLogo {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hr {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: rbg(var(--background-rgb));
|
||||
height: 2px;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
import { AdminBarClient } from './index.client'
|
||||
|
||||
export function AdminBar() {
|
||||
const { isEnabled: isPreviewMode } = draftMode()
|
||||
|
||||
return (
|
||||
<AdminBarClient
|
||||
preview={isPreviewMode}
|
||||
// id={page?.id} // TODO: is there any way to do this?!
|
||||
collection="pages"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
svg {
|
||||
margin-right: calc(var(--base) / 2);
|
||||
width: var(--base);
|
||||
height: var(--base);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.primary--white {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary--black {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.secondary--white {
|
||||
background-color: white;
|
||||
box-shadow: inset 0 0 0 1px black;
|
||||
}
|
||||
|
||||
.secondary--black {
|
||||
background-color: black;
|
||||
box-shadow: inset 0 0 0 1px white;
|
||||
}
|
||||
|
||||
.appearance--default {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { ElementType } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export type Props = {
|
||||
label?: string
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
el?: 'button' | 'link' | 'a'
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
newTab?: boolean
|
||||
className?: string
|
||||
type?: 'submit' | 'button'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
el: elFromProps = 'link',
|
||||
label,
|
||||
newTab,
|
||||
href,
|
||||
appearance,
|
||||
className: classNameFromProps,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}) => {
|
||||
let el = elFromProps
|
||||
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
const className = [
|
||||
classes.button,
|
||||
classNameFromProps,
|
||||
classes[`appearance--${appearance}`],
|
||||
classes.button,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const content = (
|
||||
<div className={classes.content}>
|
||||
{/* <Chevron /> */}
|
||||
<span className={classes.label}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (onClick || type === 'submit') el = 'button'
|
||||
|
||||
if (el === 'link') {
|
||||
return (
|
||||
<Link href={href || ''} className={className} {...newTabProps} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const Element: ElementType = el
|
||||
|
||||
return (
|
||||
<Element
|
||||
href={href}
|
||||
className={className}
|
||||
type={type}
|
||||
{...newTabProps}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{content}
|
||||
</Element>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Page } from '../../../payload-types'
|
||||
import { Button } from '../Button'
|
||||
|
||||
export type CMSLinkType = {
|
||||
type?: 'custom' | 'reference'
|
||||
url?: string
|
||||
newTab?: boolean
|
||||
reference?: {
|
||||
value: string | Page
|
||||
relationTo: 'pages'
|
||||
}
|
||||
label?: string
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
type,
|
||||
url,
|
||||
newTab,
|
||||
reference,
|
||||
label,
|
||||
appearance,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `/${reference.value.slug === 'home' ? '' : reference.value.slug}`
|
||||
: url
|
||||
|
||||
if (!appearance) {
|
||||
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
|
||||
if (type === 'custom') {
|
||||
return (
|
||||
<a href={url} {...newTabProps} className={className}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} {...newTabProps} className={className} prefetch={false}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const buttonProps = {
|
||||
newTab,
|
||||
href,
|
||||
appearance,
|
||||
label,
|
||||
}
|
||||
|
||||
return <Button className={className} {...buttonProps} el="link" />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.gutter {
|
||||
max-width: var(--max-width);
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.gutterLeft {
|
||||
padding-left: var(--gutter-h);
|
||||
}
|
||||
|
||||
.gutterRight {
|
||||
padding-right: var(--gutter-h);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { forwardRef, Ref } from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
type Props = {
|
||||
left?: boolean
|
||||
right?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
ref?: Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const { left = true, right = true, className, children } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
classes.gutter,
|
||||
left && classes.gutterLeft,
|
||||
right && classes.gutterRight,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Gutter.displayName = 'Gutter'
|
||||
@@ -0,0 +1,32 @@
|
||||
.header {
|
||||
padding: var(--base) 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: calc(var(--base) / 2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
gap: 0 calc(var(--base) / 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { MainMenu } from '../../../payload-types'
|
||||
import { CMSLink } from '../CMSLink'
|
||||
import { Gutter } from '../Gutter'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export async function Header() {
|
||||
const mainMenu: MainMenu = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CMS_URL}/api/globals/main-menu`,
|
||||
).then(res => res.json())
|
||||
|
||||
const { navItems } = mainMenu
|
||||
|
||||
const hasNavItems = navItems && Array.isArray(navItems) && navItems.length > 0
|
||||
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
<Gutter className={classes.wrap}>
|
||||
<Link href="/" className={classes.logo}>
|
||||
<picture>
|
||||
<source
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<Image
|
||||
width={150}
|
||||
height={30}
|
||||
alt="Payload Logo"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
{hasNavItems && (
|
||||
<nav className={classes.nav}>
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} />
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</Gutter>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@@ -0,0 +1,9 @@
|
||||
.richText {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
|
||||
import serialize from './serialize'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
const RichText: React.FC<{ className?: string; content: any }> = ({ className, content }) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
|
||||
{serialize(content)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichText
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import escapeHTML from 'escape-html'
|
||||
import { Text } from 'slate'
|
||||
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
type Children = Leaf[]
|
||||
|
||||
type Leaf = {
|
||||
type: string
|
||||
value?: {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
children: Children
|
||||
url?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const serialize = (children: Children): React.ReactNode[] =>
|
||||
children.map((node, i) => {
|
||||
if (Text.isText(node)) {
|
||||
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
|
||||
|
||||
if (node.bold) {
|
||||
text = <strong key={i}>{text}</strong>
|
||||
}
|
||||
|
||||
if (node.code) {
|
||||
text = <code key={i}>{text}</code>
|
||||
}
|
||||
|
||||
if (node.italic) {
|
||||
text = <em key={i}>{text}</em>
|
||||
}
|
||||
|
||||
if (node.underline) {
|
||||
text = (
|
||||
<span style={{ textDecoration: 'underline' }} key={i}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.strikethrough) {
|
||||
text = (
|
||||
<span style={{ textDecoration: 'line-through' }} key={i}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <Fragment key={i}>{text}</Fragment>
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'h1':
|
||||
return <h1 key={i}>{serialize(node.children)}</h1>
|
||||
case 'h2':
|
||||
return <h2 key={i}>{serialize(node.children)}</h2>
|
||||
case 'h3':
|
||||
return <h3 key={i}>{serialize(node.children)}</h3>
|
||||
case 'h4':
|
||||
return <h4 key={i}>{serialize(node.children)}</h4>
|
||||
case 'h5':
|
||||
return <h5 key={i}>{serialize(node.children)}</h5>
|
||||
case 'h6':
|
||||
return <h6 key={i}>{serialize(node.children)}</h6>
|
||||
case 'blockquote':
|
||||
return <blockquote key={i}>{serialize(node.children)}</blockquote>
|
||||
case 'ul':
|
||||
return <ul key={i}>{serialize(node.children)}</ul>
|
||||
case 'ol':
|
||||
return <ol key={i}>{serialize(node.children)}</ol>
|
||||
case 'li':
|
||||
return <li key={i}>{serialize(node.children)}</li>
|
||||
case 'link':
|
||||
return (
|
||||
<a href={escapeHTML(node.url)} key={i}>
|
||||
{serialize(node.children)}
|
||||
</a>
|
||||
)
|
||||
|
||||
default:
|
||||
return <p key={i}>{serialize(node.children)}</p>
|
||||
}
|
||||
})
|
||||
|
||||
export default serialize
|
||||
@@ -0,0 +1,6 @@
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
draftMode().disable()
|
||||
return new Response('Draft mode is disabled')
|
||||
}
|
||||
47
examples/draft-preview/next-app/app/api/preview/route.ts
Normal file
47
examples/draft-preview/next-app/app/api/preview/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { draftMode } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export async function GET(
|
||||
req: Request & {
|
||||
cookies: {
|
||||
get: (name: string) => {
|
||||
value: string
|
||||
}
|
||||
}
|
||||
},
|
||||
): Promise<Response> {
|
||||
const payloadToken = req.cookies.get('payload-token')?.value
|
||||
const { searchParams } = new URL(req.url)
|
||||
const url = searchParams.get('url')
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (!url) {
|
||||
return new Response('No URL provided', { status: 404 })
|
||||
}
|
||||
|
||||
if (!payloadToken) {
|
||||
new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
// validate the Payload token
|
||||
const userReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${payloadToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const userRes = await userReq.json()
|
||||
|
||||
if (!userReq.ok || !userRes?.user) {
|
||||
draftMode().disable()
|
||||
return new Response('You are not allowed to preview this page', { status: 403 })
|
||||
}
|
||||
|
||||
if (secret !== process.env.NEXT_PRIVATE_DRAFT_SECRET) {
|
||||
return new Response('Invalid token', { status: 401 })
|
||||
}
|
||||
|
||||
draftMode().enable()
|
||||
|
||||
redirect(url)
|
||||
}
|
||||
25
examples/draft-preview/next-app/app/api/revalidate/route.ts
Normal file
25
examples/draft-preview/next-app/app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<unknown> {
|
||||
const path = request.nextUrl.searchParams.get('revalidatePath')
|
||||
const secret = request.nextUrl.searchParams.get('secret')
|
||||
|
||||
if (secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY) {
|
||||
return NextResponse.json({ revalidated: false, now: Date.now() })
|
||||
}
|
||||
|
||||
if (typeof path === 'string') {
|
||||
// there is a known bug with `revalidatePath` where it will not revalidate exact paths of dynamic routes
|
||||
// instead, Next.js expects us to revalidate entire directories, i.e. `/[slug]` instead of `/example-page`
|
||||
// for now we'll make this change but with expectation that it will be fixed so we can use `revalidatePath('/example-page')`
|
||||
// - https://github.com/vercel/next.js/issues/49387
|
||||
// - https://github.com/vercel/next.js/issues/49778#issuecomment-1547028830
|
||||
// revalidatePath(path)
|
||||
revalidatePath('/[slug]')
|
||||
return NextResponse.json({ revalidated: true, now: Date.now() })
|
||||
}
|
||||
|
||||
return NextResponse.json({ revalidated: false, now: Date.now() })
|
||||
}
|
||||
107
examples/draft-preview/next-app/app/app.scss
Normal file
107
examples/draft-preview/next-app/app/app.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
$breakpoint: 1000px;
|
||||
|
||||
:root {
|
||||
--max-width: 1600px;
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
--block-spacing: 2rem;
|
||||
--gutter-h: 4rem;
|
||||
--base: 1rem;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
--block-spacing: 1rem;
|
||||
--gutter-h: 2rem;
|
||||
--base: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-rgb: 7, 7, 7;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
32
examples/draft-preview/next-app/app/layout.tsx
Normal file
32
examples/draft-preview/next-app/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AdminBar } from './_components/AdminBar'
|
||||
import { Header } from './_components/Header'
|
||||
|
||||
import './app.scss'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AdminBar />
|
||||
{/* The error ignored here is related to `@types/react` and `typescript` not
|
||||
aligning with their implementations of Promise-based server components.
|
||||
This can be removed once these dependencies are resolved in their respective modules.
|
||||
- https://github.com/vercel/next.js/issues/42292
|
||||
- https://github.com/vercel/next.js/issues/43537
|
||||
Update: this is fixed in `@types/react` v18.2.14 but still requires `@ts-expect-error` to build :shrug:
|
||||
See my comment here: https://github.com/vercel/next.js/issues/42292#issuecomment-1622979777
|
||||
*/}
|
||||
{/* @ts-expect-error */}
|
||||
<Header />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
3
examples/draft-preview/next-app/app/page.tsx
Normal file
3
examples/draft-preview/next-app/app/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Page from './[slug]/page'
|
||||
|
||||
export default Page
|
||||
5
examples/draft-preview/next-app/next-env.d.ts
vendored
Normal file
5
examples/draft-preview/next-app/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
4
examples/draft-preview/next-app/next.config.js
Normal file
4
examples/draft-preview/next-app/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
40
examples/draft-preview/next-app/package.json
Normal file
40
examples/draft-preview/next-app/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "payload-draft-preview-next-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"escape-html": "^1.0.3",
|
||||
"next": "^13.4.8",
|
||||
"payload-admin-bar": "^1.0.6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^13.4.8",
|
||||
"@payloadcms/eslint-config": "^0.0.2",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/node": "18.11.3",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"eslint": "8.41.0",
|
||||
"eslint-config-next": "13.4.3",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.62.1",
|
||||
"slate": "^0.82.0",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
59
examples/draft-preview/next-app/payload-types.ts
Normal file
59
examples/draft-preview/next-app/payload-types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* 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: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
};
|
||||
globals: {
|
||||
'main-menu': MainMenu;
|
||||
};
|
||||
}
|
||||
export interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
richText: {
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: 'draft' | 'published';
|
||||
}
|
||||
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 MainMenu {
|
||||
id: string;
|
||||
navItems?: {
|
||||
link: {
|
||||
type?: 'reference' | 'custom';
|
||||
newTab?: boolean;
|
||||
reference: {
|
||||
value: string | Page;
|
||||
relationTo: 'pages';
|
||||
};
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
id?: string;
|
||||
}[];
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
BIN
examples/draft-preview/next-app/public/favicon.ico
Normal file
BIN
examples/draft-preview/next-app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
15
examples/draft-preview/next-app/public/favicon.svg
Normal file
15
examples/draft-preview/next-app/public/favicon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
path {
|
||||
fill: #0F0F0F;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
|
||||
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
28
examples/draft-preview/next-app/tsconfig.json
Normal file
28
examples/draft-preview/next-app/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
2429
examples/draft-preview/next-app/yarn.lock
Normal file
2429
examples/draft-preview/next-app/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
10
examples/draft-preview/next-pages/.editorconfig
Normal file
10
examples/draft-preview/next-pages/.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
max_line_length = null
|
||||
2
examples/draft-preview/next-pages/.env.example
Normal file
2
examples/draft-preview/next-pages/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_CMS_URL=http://localhost:3000
|
||||
NEXT_PRIVATE_REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
|
||||
4
examples/draft-preview/next-pages/.eslintrc.js
Normal file
4
examples/draft-preview/next-pages/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['plugin:@next/next/recommended', '@payloadcms'],
|
||||
}
|
||||
6
examples/draft-preview/next-pages/.gitignore
vendored
Normal file
6
examples/draft-preview/next-pages/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
.env
|
||||
package-lock.json
|
||||
8
examples/draft-preview/next-pages/.prettierrc.js
Normal file
8
examples/draft-preview/next-pages/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
37
examples/draft-preview/next-pages/README.md
Normal file
37
examples/draft-preview/next-pages/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Payload Draft Preview Example Front-End
|
||||
|
||||
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview).
|
||||
|
||||
> This example uses the Pages Router, the legacy API of Next.js. If your app is using the latest [App Router](https://nextjs.org/docs/app), check out the official [App Router Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Payload
|
||||
|
||||
First you'll need a running Payload app. There is one made explicitly for this example and [can be found here](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload). If you have not done so already, clone it down and follow the setup instructions there. This will provide all the necessary APIs that your Next.js app requires for authentication.
|
||||
|
||||
### Next.js
|
||||
|
||||
1. Clone this repo
|
||||
2. `cd` into this directory and run `yarn` or `npm install`
|
||||
3. `cp .env.example .env` to copy the example environment variables
|
||||
4. `yarn dev` or `npm run dev` to start the server
|
||||
5. `open http://localhost:3001` to see the result
|
||||
|
||||
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload) for full details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Payload and Next.js, take a look at the following resources:
|
||||
|
||||
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
|
||||
- [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 Payload GitHub repository](https://github.com/payloadcms/payload) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deployment
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js. You could also combine this app into a [single Express server](https://github.com/payloadcms/payload/tree/master/examples/custom-server) and deploy in to [Payload Cloud](https://payloadcms.com/new/import).
|
||||
|
||||
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
5
examples/draft-preview/next-pages/next-env.d.ts
vendored
Normal file
5
examples/draft-preview/next-pages/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
10
examples/draft-preview/next-pages/next.config.js
Normal file
10
examples/draft-preview/next-pages/next.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
images: {
|
||||
domains: ['localhost', process.env.NEXT_PUBLIC_CMS_URL || ''].filter(Boolean),
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
40
examples/draft-preview/next-pages/package.json
Normal file
40
examples/draft-preview/next-pages/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "payload-draft-preview-next-pages",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"escape-html": "^1.0.3",
|
||||
"next": "^13.4.8",
|
||||
"payload-admin-bar": "^1.0.6",
|
||||
"qs": "^6.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-cookie": "^4.1.1",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^13.4.8",
|
||||
"@payloadcms/eslint-config": "^0.0.2",
|
||||
"@types/node": "18.11.3",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"eslint": "8.25.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.55.0",
|
||||
"slate": "^0.82.0",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
}
|
||||
BIN
examples/draft-preview/next-pages/public/favicon.ico
Normal file
BIN
examples/draft-preview/next-pages/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
15
examples/draft-preview/next-pages/public/favicon.svg
Normal file
15
examples/draft-preview/next-pages/public/favicon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
path {
|
||||
fill: #0F0F0F;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
|
||||
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,51 @@
|
||||
.adminBar {
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
background-color: rgba(var(--foreground-rgb), 0.075);
|
||||
padding: calc(var(--base) * 0.5) 0;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms linear;
|
||||
}
|
||||
|
||||
.payloadAdminBar {
|
||||
color: rgb(var(--foreground-rgb)) !important;
|
||||
}
|
||||
|
||||
.show {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
& > *:not(:last-child) {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: calc(var(--base) * 0.5) !important;
|
||||
}
|
||||
|
||||
.innerLogo {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hr {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: rbg(var(--background-rgb));
|
||||
height: 2px;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { PayloadAdminBar, PayloadAdminBarProps, PayloadMeUser } from 'payload-admin-bar'
|
||||
|
||||
import { Gutter } from '../Gutter'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
const Title: React.FC = () => <span>Dashboard</span>
|
||||
|
||||
export const AdminBar: React.FC<{
|
||||
adminBarProps?: PayloadAdminBarProps
|
||||
user?: PayloadMeUser
|
||||
setUser?: (user: PayloadMeUser) => void // eslint-disable-line no-unused-vars
|
||||
}> = props => {
|
||||
const { adminBarProps, user, setUser } = props
|
||||
|
||||
return (
|
||||
<div className={[classes.adminBar, user && classes.show].filter(Boolean).join(' ')}>
|
||||
<Gutter className={classes.container}>
|
||||
<PayloadAdminBar
|
||||
{...adminBarProps}
|
||||
logo={<Title />}
|
||||
cmsURL={process.env.NEXT_PUBLIC_CMS_URL}
|
||||
onAuthChange={setUser}
|
||||
className={classes.payloadAdminBar}
|
||||
classNames={{
|
||||
user: classes.user,
|
||||
logo: classes.logo,
|
||||
controls: classes.controls,
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 'unset',
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</Gutter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
svg {
|
||||
margin-right: calc(var(--base) / 2);
|
||||
width: var(--base);
|
||||
height: var(--base);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.primary--white {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary--black {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.secondary--white {
|
||||
background-color: white;
|
||||
box-shadow: inset 0 0 0 1px black;
|
||||
}
|
||||
|
||||
.secondary--black {
|
||||
background-color: black;
|
||||
box-shadow: inset 0 0 0 1px white;
|
||||
}
|
||||
|
||||
.appearance--default {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { ElementType } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
export type Props = {
|
||||
label: string
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
el?: 'button' | 'link' | 'a'
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
newTab?: boolean
|
||||
className?: string
|
||||
type?: 'submit' | 'button'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
el: elFromProps = 'link',
|
||||
label,
|
||||
newTab,
|
||||
href,
|
||||
appearance,
|
||||
className: classNameFromProps,
|
||||
onClick,
|
||||
type = 'button',
|
||||
disabled,
|
||||
}) => {
|
||||
let el = elFromProps
|
||||
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
const className = [
|
||||
classes.button,
|
||||
classNameFromProps,
|
||||
classes[`appearance--${appearance}`],
|
||||
classes.button,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const content = (
|
||||
<div className={classes.content}>
|
||||
{/* <Chevron /> */}
|
||||
<span className={classes.label}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (onClick || type === 'submit') el = 'button'
|
||||
|
||||
if (el === 'link') {
|
||||
return (
|
||||
<Link href={href} className={className} {...newTabProps} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const Element: ElementType = el
|
||||
|
||||
return (
|
||||
<Element
|
||||
href={href}
|
||||
className={className}
|
||||
type={type}
|
||||
{...newTabProps}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{content}
|
||||
</Element>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Page } from '../../payload-types'
|
||||
import { Button } from '../Button'
|
||||
|
||||
export type CMSLinkType = {
|
||||
type?: 'custom' | 'reference'
|
||||
url?: string
|
||||
newTab?: boolean
|
||||
reference?: {
|
||||
value: string | Page
|
||||
relationTo: 'pages'
|
||||
}
|
||||
label?: string
|
||||
appearance?: 'default' | 'primary' | 'secondary'
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
type,
|
||||
url,
|
||||
newTab,
|
||||
reference,
|
||||
label,
|
||||
appearance,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `/${reference.value.slug === 'home' ? '' : reference.value.slug}`
|
||||
: url
|
||||
|
||||
if (!appearance) {
|
||||
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
|
||||
|
||||
if (type === 'custom') {
|
||||
return (
|
||||
<a href={url} {...newTabProps} className={className}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} {...newTabProps} className={className} prefetch={false}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const buttonProps = {
|
||||
newTab,
|
||||
href,
|
||||
appearance,
|
||||
label,
|
||||
}
|
||||
|
||||
return <Button className={className} {...buttonProps} el="link" />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.gutter {
|
||||
max-width: var(--max-width);
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.gutterLeft {
|
||||
padding-left: var(--gutter-h);
|
||||
}
|
||||
|
||||
.gutterRight {
|
||||
padding-right: var(--gutter-h);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { forwardRef, Ref } from 'react'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
type Props = {
|
||||
left?: boolean
|
||||
right?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
ref?: Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const { left = true, right = true, className, children } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
classes.gutter,
|
||||
left && classes.gutterLeft,
|
||||
right && classes.gutterRight,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Gutter.displayName = 'Gutter'
|
||||
@@ -0,0 +1,32 @@
|
||||
.header {
|
||||
padding: var(--base) 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: calc(var(--base) / 2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
gap: 0 calc(var(--base) / 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { PayloadAdminBarProps, PayloadMeUser } from 'payload-admin-bar'
|
||||
|
||||
import { MainMenu } from '../../payload-types'
|
||||
import { AdminBar } from '../AdminBar'
|
||||
import { CMSLink } from '../CMSLink'
|
||||
import { Gutter } from '../Gutter'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
type HeaderBarProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({ children }) => {
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
<Gutter className={classes.wrap}>
|
||||
<Link href="/" className={classes.logo}>
|
||||
<picture>
|
||||
<source
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<Image
|
||||
width={150}
|
||||
height={30}
|
||||
alt="Payload Logo"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
{children}
|
||||
</Gutter>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export const Header: React.FC<{
|
||||
globals: {
|
||||
mainMenu: MainMenu
|
||||
}
|
||||
adminBarProps: PayloadAdminBarProps
|
||||
}> = props => {
|
||||
const { globals, adminBarProps } = props
|
||||
|
||||
const [user, setUser] = useState<PayloadMeUser>()
|
||||
|
||||
const {
|
||||
mainMenu: { navItems },
|
||||
} = globals
|
||||
|
||||
const hasNavItems = navItems && Array.isArray(navItems) && navItems.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminBar adminBarProps={adminBarProps} user={user} setUser={setUser} />
|
||||
<HeaderBar>
|
||||
{hasNavItems && (
|
||||
<nav className={classes.nav}>
|
||||
{navItems.map(({ link }, i) => {
|
||||
return <CMSLink key={i} {...link} />
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</HeaderBar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.richText {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
|
||||
import serialize from './serialize'
|
||||
|
||||
import classes from './index.module.scss'
|
||||
|
||||
const RichText: React.FC<{ className?: string; content: any }> = ({ className, content }) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
|
||||
{serialize(content)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichText
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import escapeHTML from 'escape-html'
|
||||
import { Text } from 'slate'
|
||||
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
type Children = Leaf[]
|
||||
|
||||
type Leaf = {
|
||||
type: string
|
||||
value?: {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
children?: Children
|
||||
url?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const serialize = (children: Children): React.ReactElement[] =>
|
||||
children.map((node, i) => {
|
||||
if (Text.isText(node)) {
|
||||
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
|
||||
|
||||
if (node.bold) {
|
||||
text = <strong key={i}>{text}</strong>
|
||||
}
|
||||
|
||||
if (node.code) {
|
||||
text = <code key={i}>{text}</code>
|
||||
}
|
||||
|
||||
if (node.italic) {
|
||||
text = <em key={i}>{text}</em>
|
||||
}
|
||||
|
||||
if (node.underline) {
|
||||
text = (
|
||||
<span style={{ textDecoration: 'underline' }} key={i}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.strikethrough) {
|
||||
text = (
|
||||
<span style={{ textDecoration: 'line-through' }} key={i}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <Fragment key={i}>{text}</Fragment>
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'h1':
|
||||
return <h1 key={i}>{serialize(node.children)}</h1>
|
||||
case 'h2':
|
||||
return <h2 key={i}>{serialize(node.children)}</h2>
|
||||
case 'h3':
|
||||
return <h3 key={i}>{serialize(node.children)}</h3>
|
||||
case 'h4':
|
||||
return <h4 key={i}>{serialize(node.children)}</h4>
|
||||
case 'h5':
|
||||
return <h5 key={i}>{serialize(node.children)}</h5>
|
||||
case 'h6':
|
||||
return <h6 key={i}>{serialize(node.children)}</h6>
|
||||
case 'blockquote':
|
||||
return <blockquote key={i}>{serialize(node.children)}</blockquote>
|
||||
case 'ul':
|
||||
return <ul key={i}>{serialize(node.children)}</ul>
|
||||
case 'ol':
|
||||
return <ol key={i}>{serialize(node.children)}</ol>
|
||||
case 'li':
|
||||
return <li key={i}>{serialize(node.children)}</li>
|
||||
case 'link':
|
||||
return (
|
||||
<a href={escapeHTML(node.url)} key={i}>
|
||||
{serialize(node.children)}
|
||||
</a>
|
||||
)
|
||||
|
||||
default:
|
||||
return <p key={i}>{serialize(node.children)}</p>
|
||||
}
|
||||
})
|
||||
|
||||
export default serialize
|
||||
@@ -0,0 +1,3 @@
|
||||
.page {
|
||||
margin-top: calc(var(--base) * 2);
|
||||
}
|
||||
130
examples/draft-preview/next-pages/src/pages/[slug].tsx
Normal file
130
examples/draft-preview/next-pages/src/pages/[slug].tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'
|
||||
import QueryString from 'qs'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
import { Gutter } from '../components/Gutter'
|
||||
import RichText from '../components/RichText'
|
||||
import type { MainMenu, Page as PageType } from '../payload-types'
|
||||
|
||||
import classes from './[slug].module.scss';
|
||||
|
||||
const Page: React.FC<
|
||||
PageType & {
|
||||
mainMenu: MainMenu
|
||||
preview?: boolean
|
||||
}
|
||||
> = props => {
|
||||
const { title, richText } = props
|
||||
|
||||
return (
|
||||
<main className={classes.page}>
|
||||
<Gutter>
|
||||
<h1>{title}</h1>
|
||||
<RichText content={richText} />
|
||||
</Gutter>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
|
||||
interface IParams extends ParsedUrlQuery {
|
||||
slug: string
|
||||
}
|
||||
|
||||
// when 'preview' cookies are set in the browser, getStaticProps runs on every request :)
|
||||
export const getStaticProps: GetStaticProps = async (context: GetStaticPropsContext) => {
|
||||
const { preview, previewData, params } = context
|
||||
|
||||
const { payloadToken } =
|
||||
(previewData as {
|
||||
payloadToken: string
|
||||
}) || {}
|
||||
|
||||
let { slug } = (params as IParams) || {}
|
||||
if (!slug) slug = 'home'
|
||||
|
||||
let doc = {}
|
||||
const notFound = false
|
||||
|
||||
const lowerCaseSlug = slug.toLowerCase() // NOTE: let the url be case insensitive
|
||||
|
||||
const searchParams = QueryString.stringify(
|
||||
{
|
||||
where: {
|
||||
slug: {
|
||||
equals: lowerCaseSlug,
|
||||
},
|
||||
},
|
||||
depth: 1,
|
||||
draft: preview ? true : undefined,
|
||||
},
|
||||
{
|
||||
encode: false,
|
||||
addQueryPrefix: true,
|
||||
},
|
||||
)
|
||||
|
||||
// when previewing, send the payload token to bypass draft access control
|
||||
const pageReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages${searchParams}`, {
|
||||
headers: {
|
||||
...(preview
|
||||
? {
|
||||
Authorization: `JWT ${payloadToken}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (pageReq.ok) {
|
||||
const pageData = await pageReq.json()
|
||||
doc = pageData.docs[0]
|
||||
if (!doc) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...doc,
|
||||
preview: preview || null,
|
||||
collection: 'pages',
|
||||
},
|
||||
notFound,
|
||||
revalidate: 3600, // in seconds
|
||||
}
|
||||
}
|
||||
|
||||
type Path = {
|
||||
params: {
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
type Paths = Path[]
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
let paths: Paths = []
|
||||
|
||||
const pagesReq = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages?where[_status][equals]=published&depth=0&limit=300`,
|
||||
)
|
||||
|
||||
const pagesData = await pagesReq.json()
|
||||
|
||||
if (pagesReq?.ok) {
|
||||
const { docs: pages } = pagesData
|
||||
|
||||
if (pages && Array.isArray(pages) && pages.length > 0) {
|
||||
paths = pages.map(page => ({ params: { slug: page.slug } }))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: true,
|
||||
}
|
||||
}
|
||||
81
examples/draft-preview/next-pages/src/pages/_app.tsx
Normal file
81
examples/draft-preview/next-pages/src/pages/_app.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { CookiesProvider } from 'react-cookie'
|
||||
import App, { AppContext, AppProps as NextAppProps } from 'next/app'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { Header } from '../components/Header'
|
||||
import { MainMenu } from '../payload-types'
|
||||
|
||||
import './app.scss'
|
||||
|
||||
export interface IGlobals {
|
||||
mainMenu: MainMenu
|
||||
}
|
||||
|
||||
export const getAllGlobals = async (): Promise<IGlobals> => {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/globals/main-menu?depth=1`)
|
||||
const mainMenu = await res.json()
|
||||
|
||||
return {
|
||||
mainMenu,
|
||||
}
|
||||
}
|
||||
|
||||
type AppProps<P = any> = {
|
||||
pageProps: P
|
||||
} & Omit<NextAppProps<P>, 'pageProps'>
|
||||
|
||||
const PayloadApp = (
|
||||
appProps: AppProps & {
|
||||
globals: IGlobals
|
||||
},
|
||||
): React.ReactElement => {
|
||||
const { Component, pageProps, globals } = appProps
|
||||
|
||||
const { collection, id, preview } = pageProps
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const onPreviewExit = useCallback(() => {
|
||||
const exit = async () => {
|
||||
const exitReq = await fetch('/api/exit-preview')
|
||||
if (exitReq.status === 200) {
|
||||
router.reload()
|
||||
}
|
||||
}
|
||||
exit()
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<Header
|
||||
globals={globals}
|
||||
adminBarProps={{
|
||||
collection,
|
||||
id,
|
||||
preview,
|
||||
onPreviewExit,
|
||||
}}
|
||||
/>
|
||||
{/* typescript flags this `@ts-expect-error` declaration as unneeded, but types are breaking the build process
|
||||
Remove these comments when the issue is resolved
|
||||
See more here: https://github.com/facebook/react/issues/24304
|
||||
*/}
|
||||
{/* @ts-expect-error */}
|
||||
<Component {...pageProps} />
|
||||
</CookiesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
PayloadApp.getInitialProps = async (appContext: AppContext) => {
|
||||
const appProps = await App.getInitialProps(appContext)
|
||||
|
||||
const globals = await getAllGlobals()
|
||||
|
||||
return {
|
||||
...appProps,
|
||||
globals,
|
||||
}
|
||||
}
|
||||
|
||||
export default PayloadApp
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const exitPreview = (req: NextApiRequest, res: NextApiResponse): void => {
|
||||
res.clearPreviewData()
|
||||
res.writeHead(200)
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default exitPreview
|
||||
44
examples/draft-preview/next-pages/src/pages/api/preview.ts
Normal file
44
examples/draft-preview/next-pages/src/pages/api/preview.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
const preview = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
|
||||
const {
|
||||
cookies: { 'payload-token': payloadToken },
|
||||
query: { url },
|
||||
} = req
|
||||
|
||||
if (!url) {
|
||||
return res.status(404).json({
|
||||
message: 'No URL provided',
|
||||
})
|
||||
}
|
||||
|
||||
if (!payloadToken) {
|
||||
return res.status(403).json({
|
||||
message: 'You are not allowed to preview this page',
|
||||
})
|
||||
}
|
||||
|
||||
// validate the Payload token
|
||||
const userReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/users/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${payloadToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const userRes = await userReq.json()
|
||||
|
||||
if (!userReq.ok || !userRes?.user) {
|
||||
return res.status(401).json({
|
||||
message: 'You are not allowed to preview this page',
|
||||
})
|
||||
}
|
||||
|
||||
res.setPreviewData({
|
||||
payloadToken,
|
||||
})
|
||||
|
||||
res.redirect(url as string)
|
||||
}
|
||||
|
||||
export default preview
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const revalidate = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
|
||||
// Check for secret to confirm this is a valid request
|
||||
if (req.query.secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY) {
|
||||
return res.status(401).json({ message: 'Invalid token' })
|
||||
}
|
||||
|
||||
if (typeof req.query.revalidatePath === 'string') {
|
||||
try {
|
||||
await res.revalidate(req.query.revalidatePath)
|
||||
return res.json({ revalidated: true })
|
||||
} catch (err: unknown) {
|
||||
// If there was an error, Next.js will continue
|
||||
// to show the last successfully generated page
|
||||
return res.status(500).send('Error revalidating')
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(400).send('No path to revalidate')
|
||||
}
|
||||
|
||||
export default revalidate
|
||||
214
examples/draft-preview/next-pages/src/pages/app.scss
Normal file
214
examples/draft-preview/next-pages/src/pages/app.scss
Normal file
@@ -0,0 +1,214 @@
|
||||
$breakpoint: 1000px;
|
||||
|
||||
:root {
|
||||
--max-width: 1600px;
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
--block-spacing: 2rem;
|
||||
--gutter-h: 4rem;
|
||||
--base: 1rem;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
--block-spacing: 1rem;
|
||||
--gutter-h: 2rem;
|
||||
--base: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-rgb: 7, 7, 7;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
$breakpoint: 1000px;
|
||||
|
||||
:root {
|
||||
--max-width: 1600px;
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
--block-spacing: 2rem;
|
||||
--gutter-h: 4rem;
|
||||
--base: 1rem;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
--block-spacing: 1rem;
|
||||
--gutter-h: 2rem;
|
||||
--base: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-rgb: 7, 7, 7;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2.5rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
10
examples/draft-preview/next-pages/src/pages/index.tsx
Normal file
10
examples/draft-preview/next-pages/src/pages/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { GetStaticProps } from 'next'
|
||||
|
||||
import Page, { getStaticProps as sharedGetStaticProps } from './[slug]'
|
||||
|
||||
export default Page
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ctx => {
|
||||
const func = sharedGetStaticProps.bind(this)
|
||||
return func(ctx)
|
||||
}
|
||||
59
examples/draft-preview/next-pages/src/payload-types.ts
Normal file
59
examples/draft-preview/next-pages/src/payload-types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* 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: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
};
|
||||
globals: {
|
||||
'main-menu': MainMenu;
|
||||
};
|
||||
}
|
||||
export interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
richText: {
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: 'draft' | 'published';
|
||||
}
|
||||
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 MainMenu {
|
||||
id: string;
|
||||
navItems?: {
|
||||
link: {
|
||||
type?: 'reference' | 'custom';
|
||||
newTab?: boolean;
|
||||
reference: {
|
||||
value: string | Page;
|
||||
relationTo: 'pages';
|
||||
};
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
id?: string;
|
||||
}[];
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
30
examples/draft-preview/next-pages/tsconfig.json
Normal file
30
examples/draft-preview/next-pages/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1950
examples/draft-preview/next-pages/yarn.lock
Normal file
1950
examples/draft-preview/next-pages/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
9
examples/draft-preview/payload/.env.example
Normal file
9
examples/draft-preview/payload/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
MONGODB_URI=mongodb://127.0.0.1/payload-example-draft-preview
|
||||
PAYLOAD_SECRET=ENTER-STRING-HERE
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3001
|
||||
PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
|
||||
COOKIE_DOMAIN=localhost
|
||||
REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
|
||||
PAYLOAD_PUBLIC_SEED=true
|
||||
PAYLOAD_DROP_DATABASE=true
|
||||
4
examples/draft-preview/payload/.eslintrc.js
Normal file
4
examples/draft-preview/payload/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@payloadcms'],
|
||||
}
|
||||
5
examples/draft-preview/payload/.gitignore
vendored
Normal file
5
examples/draft-preview/payload/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
8
examples/draft-preview/payload/.prettierrc.js
Normal file
8
examples/draft-preview/payload/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: "all",
|
||||
arrowParens: "avoid",
|
||||
};
|
||||
21
examples/draft-preview/payload/.vscode/launch.json
vendored
Normal file
21
examples/draft-preview/payload/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Preview Example CMS",
|
||||
"program": "${workspaceFolder}/src/server.ts",
|
||||
"preLaunchTask": "npm: build:server",
|
||||
"env": {
|
||||
"PAYLOAD_CONFIG_PATH": "${workspaceFolder}/src/payload.config.ts"
|
||||
},
|
||||
// "outFiles": [
|
||||
// "${workspaceFolder}/dist/**/*.js"
|
||||
// ]
|
||||
},
|
||||
]
|
||||
}
|
||||
101
examples/draft-preview/payload/README.md
Normal file
101
examples/draft-preview/payload/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Payload Draft Preview Example
|
||||
|
||||
The [Payload Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview) demonstrates how to implement draft preview in [Payload](https://github.com/payloadcms/payload) using [Versions](https://payloadcms.com/docs/versions/overview) and [Drafts](https://payloadcms.com/docs/versions/drafts). Draft preview allows you to see content on your front-end before it is published. There are various fully working front-ends made explicitly for this example, including:
|
||||
|
||||
- [Next.js App Router](../next-app)
|
||||
- [Next.js Pages Router](../next-pages)
|
||||
|
||||
Follow the instructions in each respective README to get started. If you are setting up draft preview for another front-end, please consider contributing to this repo with your own example!
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Clone this repo
|
||||
2. `cd` into this directory and run `yarn` or `npm install`
|
||||
3. `cp .env.example .env` to copy the example environment variables
|
||||
4. `yarn dev` or `npm run dev` to start the server and seed the database
|
||||
5. `open http://localhost:3000/admin` to access the admin panel
|
||||
6. Login with email `demo@payloadcms.com` and password `demo`
|
||||
|
||||
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details.
|
||||
|
||||
## How it works
|
||||
|
||||
Draft preview works by sending the user to your front-end with a `secret` along with their http-only cookies. Your front-end catches the request, verifies the authenticity, then enters into it's own preview mode. Once in preview mode, your front-end can begin securely requesting draft documents from Payload. See [Preview Mode](#preview-mode) for more details.
|
||||
|
||||
### Collections
|
||||
|
||||
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend any of this functionality.
|
||||
|
||||
- #### Users
|
||||
|
||||
The `users` collection is auth-enabled which provides access to the admin panel. When previewing documents on your front-end, the user's JWT is used to authenticate the request. See [Pages](#pages) for more details.
|
||||
|
||||
For additional help with authentication, see the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs or the official [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth).
|
||||
|
||||
- #### Pages
|
||||
|
||||
The `pages` collection is draft-enabled and has access control that restricts public users from viewing pages with a `_status` of `draft`. To fetch draft documents on your front-end, simply include the `draft=true` query param along with the `Authorization` header once you have entered [Preview Mode](#preview-mode).
|
||||
|
||||
```ts
|
||||
const preview = true; // set this based on your own front-end environment (see `Preview Mode` below)
|
||||
const pageSlug = 'example-page'; // same here
|
||||
const searchParams = `?where[slug][equals]=${pageSlug}&depth=1${preview ? `&draft=true` : ''}`
|
||||
|
||||
// when previewing, send the payload token to bypass draft access control
|
||||
const pageReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages${searchParams}`, {
|
||||
headers: {
|
||||
...preview ? {
|
||||
Authorization: `JWT ${payloadToken}`,
|
||||
} : {},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For more details on how to extend this functionality, see the [Authentication](https://payloadcms.com/docs/authentication) docs.
|
||||
|
||||
### Preview Mode
|
||||
|
||||
To preview draft documents, the user first needs to have at least one draft document saved. When they click the "preview" button from the Payload admin panel, a custom [preview function](https://payloadcms.com/docs/configuration/collections#preview) routes them to your front-end with a `secret` along with their http-only cookies. An API route on your front-end will verify the secret and token before entering into it's own preview mode. Once in preview mode, it can begin requesting drafts from Payload using the `Authorization` header. See [Pages](#pages) for more details.
|
||||
|
||||
> "Preview mode" looks differently for every front-end framework. For instance, check out the differences between Next.js [Preview Mode](https://nextjs.org/docs/pages/building-your-application/configuring/preview-mode) in the Pages Router and [Draft Mode](https://nextjs.org/docs/pages/building-your-application/configuring/draft-mode) in the App Router. In Next.js, methods are provided that set cookies in your browser, but this may not be the case for all frameworks.
|
||||
|
||||
### On-demand Revalidation
|
||||
|
||||
If your front-end is statically generated then you may also want to regenerate the HTML for each page individually as they are published, referred to as On-demand Revalidation. This will prevent your static site from having to fully rebuild every page in order to deploy content changes. To do this, we add an `afterChange` hook to the collection that fires a request to your front-end in the background each time the document is updated. You can handle this request on your front-end to revalidate the HTML for your page.
|
||||
|
||||
> On-demand revalidation looks differently for every front-end framework. For instance, check out the differences between Next.js on-demand revalidation in the [Pages Router](https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration) and the [App Router](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#on-demand-revalidation). In Next.js, methods are provided that regenerate the HTML for each page, but this may not be the case for all frameworks.
|
||||
|
||||
### Admin Bar
|
||||
|
||||
You might also want to render an admin bar on your front-end so that logged-in users can quickly navigate between the front-end and Payload as they're editing. For React apps, check out the official [Payload Admin Bar](https://github.com/payloadcms/payload-admin-bar). For other frameworks, simply hit the `/me` route with `credentials: 'include'` and render your own admin bar if the user is logged in.
|
||||
|
||||
### CORS
|
||||
|
||||
The [`cors`](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors), [`csrf`](https://payloadcms.com/docs/production/preventing-abuse#cross-site-request-forgery-csrf), and [`cookies`](https://payloadcms.com/docs/authentication/config#options) settings are configured to ensure that the admin panel and front-end can communicate with each other securely. If you are combining your front-end and admin panel into a single application that runs of a shared port and domain, you can simplify your config by removing these settings.
|
||||
|
||||
For more details on this, see the [CORS](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors) docs.
|
||||
|
||||
## Development
|
||||
|
||||
To spin up this example locally, follow the [Quick Start](#quick-start).
|
||||
|
||||
### Seed
|
||||
|
||||
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_PUBLIC_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates a user with email `demo@payloadcms.com` and password `demo` along with a home page and an example page with two versions, one published and the other draft.
|
||||
|
||||
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
||||
|
||||
## Production
|
||||
|
||||
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
|
||||
|
||||
1. First invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
|
||||
1. Then run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
|
||||
|
||||
### Deployment
|
||||
|
||||
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details.
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
4
examples/draft-preview/payload/nodemon.json
Normal file
4
examples/draft-preview/payload/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ext": "ts",
|
||||
"exec": "ts-node src/server.ts"
|
||||
}
|
||||
46
examples/draft-preview/payload/package.json
Normal file
46
examples/draft-preview/payload/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "payload-example-preview",
|
||||
"description": "Payload preview example.",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/server.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||
"seed": "rm -rf media && cross-env PAYLOAD_PUBLIC_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||
"build:server": "tsc",
|
||||
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
|
||||
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
|
||||
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
|
||||
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint --fix --ext .ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"payload": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "^0.0.2",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/node": "18.11.3",
|
||||
"@types/react": "18.0.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const loggedIn: Access = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const publishedOrLoggedIn: Access = ({ req: { user } }) => {
|
||||
if (user) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FieldHook } from 'payload/types'
|
||||
|
||||
const format = (val: string): string =>
|
||||
val
|
||||
.replace(/ /g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
const formatSlug =
|
||||
(fallback: string): FieldHook =>
|
||||
({ operation, value, originalDoc, data }) => {
|
||||
if (typeof value === 'string') {
|
||||
return format(value)
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
|
||||
|
||||
if (fallbackData && typeof fallbackData === 'string') {
|
||||
return format(fallbackData)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export default formatSlug
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AfterChangeHook } from 'payload/dist/collections/config/types'
|
||||
|
||||
// ensure that the home page is revalidated at '/' instead of '/home'
|
||||
export const formatAppURL = ({ doc }): string => {
|
||||
const pathToUse = doc.slug === 'home' ? '' : doc.slug
|
||||
const { pathname } = new URL(`${process.env.PAYLOAD_PUBLIC_SITE_URL}/${pathToUse}`)
|
||||
return pathname
|
||||
}
|
||||
|
||||
// Revalidate the page in the background, so the user doesn't have to wait
|
||||
// Notice that the hook itself is not async and we are not awaiting `revalidate`
|
||||
// Only revalidate existing docs that are published
|
||||
export const revalidatePage: AfterChangeHook = ({ doc, req, operation }) => {
|
||||
if (operation === 'update' && doc._status === 'published') {
|
||||
const url = formatAppURL({ doc })
|
||||
|
||||
const revalidate = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.PAYLOAD_PUBLIC_SITE_URL}/api/revalidate?secret=${process.env.REVALIDATION_KEY}&revalidatePath=${url}`,
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
req.payload.logger.info(`Revalidated path ${url}`)
|
||||
} else {
|
||||
req.payload.logger.error(`Error revalidating path ${url}`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
req.payload.logger.error(`Error hitting revalidate route for ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
revalidate()
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
import richText from '../../fields/richText'
|
||||
import { loggedIn } from './access/loggedIn'
|
||||
import { publishedOrLoggedIn } from './access/publishedOrLoggedIn'
|
||||
import formatSlug from './hooks/formatSlug'
|
||||
import { formatAppURL, revalidatePage } from './hooks/revalidatePage'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
preview: doc => {
|
||||
return `${process.env.PAYLOAD_PUBLIC_SITE_URL}/api/preview?url=${encodeURIComponent(
|
||||
formatAppURL({
|
||||
doc,
|
||||
}),
|
||||
)}&secret=${process.env.PAYLOAD_PUBLIC_DRAFT_SECRET}`
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
access: {
|
||||
read: publishedOrLoggedIn,
|
||||
create: loggedIn,
|
||||
update: loggedIn,
|
||||
delete: loggedIn,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [revalidatePage],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
label: 'Slug',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [formatSlug('title')],
|
||||
},
|
||||
},
|
||||
richText(),
|
||||
],
|
||||
}
|
||||
17
examples/draft-preview/payload/src/collections/Users.ts
Normal file
17
examples/draft-preview/payload/src/collections/Users.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
tokenExpiration: 28800, // 8 hours
|
||||
cookies: {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
domain: process.env.COOKIE_DOMAIN,
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
fields: [],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
const BeforeLogin: React.FC = () => {
|
||||
if (process.env.PAYLOAD_PUBLIC_SEED === 'true') {
|
||||
return (
|
||||
<p>
|
||||
{'Log in with the email '}
|
||||
<strong>demo@payloadcms.com</strong>
|
||||
{' and the password '}
|
||||
<strong>demo</strong>.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default BeforeLogin
|
||||
145
examples/draft-preview/payload/src/fields/link.ts
Normal file
145
examples/draft-preview/payload/src/fields/link.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import deepMerge from '../utilities/deepMerge'
|
||||
|
||||
export const appearanceOptions = {
|
||||
primary: {
|
||||
label: 'Primary Button',
|
||||
value: 'primary',
|
||||
},
|
||||
secondary: {
|
||||
label: 'Secondary Button',
|
||||
value: 'secondary',
|
||||
},
|
||||
default: {
|
||||
label: 'Default',
|
||||
value: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
export type LinkAppearances = 'primary' | 'secondary' | 'default'
|
||||
|
||||
type LinkType = (options?: {
|
||||
appearances?: LinkAppearances[] | false
|
||||
disableLabel?: boolean
|
||||
overrides?: Record<string, unknown>
|
||||
}) => Field
|
||||
|
||||
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
|
||||
const linkResult: Field = {
|
||||
name: 'link',
|
||||
type: 'group',
|
||||
admin: {
|
||||
hideGutter: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{
|
||||
label: 'Internal link',
|
||||
value: 'reference',
|
||||
},
|
||||
{
|
||||
label: 'Custom URL',
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
defaultValue: 'reference',
|
||||
admin: {
|
||||
layout: 'horizontal',
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newTab',
|
||||
label: 'Open in new tab',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
width: '50%',
|
||||
style: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const linkTypes: Field[] = [
|
||||
{
|
||||
name: 'reference',
|
||||
label: 'Document to link to',
|
||||
type: 'relationship',
|
||||
relationTo: ['pages'],
|
||||
required: true,
|
||||
maxDepth: 1,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'reference',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'Custom URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'custom',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!disableLabel) {
|
||||
linkTypes[0].admin.width = '50%'
|
||||
linkTypes[1].admin.width = '50%'
|
||||
|
||||
linkResult.fields.push({
|
||||
type: 'row',
|
||||
fields: [
|
||||
...linkTypes,
|
||||
{
|
||||
name: 'label',
|
||||
label: 'Label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
linkResult.fields = [...linkResult.fields, ...linkTypes]
|
||||
}
|
||||
|
||||
if (appearances !== false) {
|
||||
let appearanceOptionsToUse = [
|
||||
appearanceOptions.default,
|
||||
appearanceOptions.primary,
|
||||
appearanceOptions.secondary,
|
||||
]
|
||||
|
||||
if (appearances) {
|
||||
appearanceOptionsToUse = appearances.map(appearance => appearanceOptions[appearance])
|
||||
}
|
||||
|
||||
linkResult.fields.push({
|
||||
name: 'appearance',
|
||||
type: 'select',
|
||||
defaultValue: 'default',
|
||||
options: appearanceOptionsToUse,
|
||||
admin: {
|
||||
description: 'Choose how the link should be rendered.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return deepMerge(linkResult, overrides)
|
||||
}
|
||||
|
||||
export default link
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RichTextElement } from 'payload/dist/fields/config/types'
|
||||
|
||||
const elements: RichTextElement[] = ['blockquote', 'h2', 'h3', 'h4', 'h5', 'h6', 'link']
|
||||
|
||||
export default elements
|
||||
86
examples/draft-preview/payload/src/fields/richText/index.ts
Normal file
86
examples/draft-preview/payload/src/fields/richText/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { RichTextElement, RichTextField, RichTextLeaf } from 'payload/dist/fields/config/types'
|
||||
|
||||
import deepMerge from '../../utilities/deepMerge'
|
||||
import link from '../link'
|
||||
import elements from './elements'
|
||||
import leaves from './leaves'
|
||||
|
||||
type RichText = (
|
||||
overrides?: Partial<RichTextField>,
|
||||
additions?: {
|
||||
elements?: RichTextElement[]
|
||||
leaves?: RichTextLeaf[]
|
||||
},
|
||||
) => RichTextField
|
||||
|
||||
const richText: RichText = (
|
||||
overrides,
|
||||
additions = {
|
||||
elements: [],
|
||||
leaves: [],
|
||||
},
|
||||
) =>
|
||||
deepMerge<RichTextField, Partial<RichTextField>>(
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
admin: {
|
||||
upload: {
|
||||
collections: {
|
||||
media: {
|
||||
fields: [
|
||||
{
|
||||
type: 'richText',
|
||||
name: 'caption',
|
||||
label: 'Caption',
|
||||
admin: {
|
||||
elements: [...elements],
|
||||
leaves: [...leaves],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'alignment',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{
|
||||
label: 'Left',
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Center',
|
||||
value: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Right',
|
||||
value: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'enableLink',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Link',
|
||||
},
|
||||
link({
|
||||
appearances: false,
|
||||
disableLabel: true,
|
||||
overrides: {
|
||||
admin: {
|
||||
condition: (_, data) => Boolean(data?.enableLink),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: [...elements, ...(additions.elements || [])],
|
||||
leaves: [...leaves, ...(additions.leaves || [])],
|
||||
},
|
||||
},
|
||||
overrides,
|
||||
)
|
||||
|
||||
export default richText
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RichTextLeaf } from 'payload/dist/fields/config/types'
|
||||
|
||||
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline']
|
||||
|
||||
export default defaultLeaves
|
||||
22
examples/draft-preview/payload/src/globals/MainMenu.ts
Normal file
22
examples/draft-preview/payload/src/globals/MainMenu.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GlobalConfig } from 'payload/types'
|
||||
|
||||
import link from '../fields/link'
|
||||
|
||||
export const MainMenu: GlobalConfig = {
|
||||
slug: 'main-menu',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'navItems',
|
||||
type: 'array',
|
||||
maxRows: 6,
|
||||
fields: [
|
||||
link({
|
||||
appearances: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
59
examples/draft-preview/payload/src/payload-types.ts
Normal file
59
examples/draft-preview/payload/src/payload-types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* 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: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
};
|
||||
globals: {
|
||||
'main-menu': MainMenu;
|
||||
};
|
||||
}
|
||||
export interface Page {
|
||||
id: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
richText: {
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: 'draft' | 'published';
|
||||
}
|
||||
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 MainMenu {
|
||||
id: string;
|
||||
navItems?: {
|
||||
link: {
|
||||
type?: 'reference' | 'custom';
|
||||
newTab?: boolean;
|
||||
reference: {
|
||||
value: string | Page;
|
||||
relationTo: 'pages';
|
||||
};
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
id?: string;
|
||||
}[];
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
29
examples/draft-preview/payload/src/payload.config.ts
Normal file
29
examples/draft-preview/payload/src/payload.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload/config'
|
||||
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Users } from './collections/Users'
|
||||
import BeforeLogin from './components/BeforeLogin'
|
||||
import { MainMenu } from './globals/MainMenu'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Pages, Users],
|
||||
admin: {
|
||||
components: {
|
||||
beforeLogin: [BeforeLogin],
|
||||
},
|
||||
},
|
||||
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
|
||||
cors: [
|
||||
process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
|
||||
process.env.PAYLOAD_PUBLIC_SITE_URL || '',
|
||||
].filter(Boolean),
|
||||
csrf: [
|
||||
process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
|
||||
process.env.PAYLOAD_PUBLIC_SITE_URL || '',
|
||||
].filter(Boolean),
|
||||
globals: [MainMenu],
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
76
examples/draft-preview/payload/src/seed/home.ts
Normal file
76
examples/draft-preview/payload/src/seed/home.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Page } from '../payload-types'
|
||||
|
||||
export const home: Partial<Page> = {
|
||||
title: 'Home Page',
|
||||
slug: 'home',
|
||||
_status: 'published',
|
||||
richText: [
|
||||
{
|
||||
children: [
|
||||
{ text: 'This is a ' },
|
||||
{ type: 'link', newTab: true, url: 'https://nextjs.org/', children: [{ text: '' }] },
|
||||
{ text: '' },
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
url: 'https://nextjs.org/',
|
||||
newTab: true,
|
||||
children: [{ text: 'Next.js' }],
|
||||
},
|
||||
{ text: " app made explicitly for Payload's " },
|
||||
{
|
||||
type: 'link',
|
||||
newTab: true,
|
||||
url: 'https://github.com/payloadcms/payload/tree/master/examples/redirects',
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
{ text: '' },
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
newTab: true,
|
||||
url: 'https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload',
|
||||
children: [{ text: 'Draft Preview Example' }],
|
||||
},
|
||||
{ text: '. This example demonstrates how to implement draft preview into Payload using ' },
|
||||
{
|
||||
type: 'link',
|
||||
newTab: true,
|
||||
url: 'https://payloadcms.com/docs/versions/drafts#drafts',
|
||||
children: [{ text: 'Drafts' }],
|
||||
},
|
||||
{ text: '.' },
|
||||
],
|
||||
},
|
||||
{ children: [{ text: '' }] },
|
||||
{
|
||||
children: [
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
url: 'http://localhost:3000/admin',
|
||||
newTab: true,
|
||||
children: [{ text: 'Log in to the admin panel' }],
|
||||
},
|
||||
{ text: ' and refresh this page to see the ' },
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
newTab: true,
|
||||
url: 'https://github.com/payloadcms/payload-admin-bar',
|
||||
children: [{ text: 'Payload Admin Bar' }],
|
||||
},
|
||||
{
|
||||
text: ' appear at the top of this site. This will allow you to seamlessly navigate between the two apps. Then, navigate to the ',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
url: 'http://localhost:3001/example-page',
|
||||
children: [{ text: 'example page' }],
|
||||
},
|
||||
{ text: ' to see how we control access to draft content. ' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
72
examples/draft-preview/payload/src/seed/index.ts
Normal file
72
examples/draft-preview/payload/src/seed/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { home } from './home'
|
||||
import { examplePage } from './page'
|
||||
import { examplePageDraft } from './pageDraft'
|
||||
|
||||
export const seed = async (payload: Payload): Promise<void> => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'demo@payloadcms.com',
|
||||
password: 'demo',
|
||||
},
|
||||
})
|
||||
|
||||
const { id: examplePageID } = await payload.create({
|
||||
collection: 'pages',
|
||||
data: examplePage as any, // eslint-disable-line
|
||||
})
|
||||
|
||||
await payload.update({
|
||||
collection: 'pages',
|
||||
id: examplePageID,
|
||||
draft: true,
|
||||
data: examplePageDraft as any, // eslint-disable-line
|
||||
})
|
||||
|
||||
const homepageJSON = JSON.parse(JSON.stringify(home).replace('{{DRAFT_PAGE_ID}}', examplePageID))
|
||||
|
||||
const { id: homePageID } = await payload.create({
|
||||
collection: 'pages',
|
||||
data: homepageJSON,
|
||||
})
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'main-menu',
|
||||
data: {
|
||||
navItems: [
|
||||
{
|
||||
link: {
|
||||
type: 'reference',
|
||||
reference: {
|
||||
relationTo: 'pages',
|
||||
value: homePageID,
|
||||
},
|
||||
label: 'Home',
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
link: {
|
||||
type: 'reference',
|
||||
reference: {
|
||||
relationTo: 'pages',
|
||||
value: examplePageID,
|
||||
},
|
||||
label: 'Example Page',
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
link: {
|
||||
type: 'custom',
|
||||
reference: null,
|
||||
label: 'Dashboard',
|
||||
url: 'http://localhost:3000/admin',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
33
examples/draft-preview/payload/src/seed/page.ts
Normal file
33
examples/draft-preview/payload/src/seed/page.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Page } from '../payload-types'
|
||||
|
||||
export const examplePage: Partial<Page> = {
|
||||
title: 'Example Page',
|
||||
slug: 'example-page',
|
||||
_status: 'published',
|
||||
richText: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: 'This is an example page with two versions, draft and published. You are currently seeing ',
|
||||
},
|
||||
{
|
||||
text: 'published',
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
text: ' content because you are not in preview mode. ',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
url: 'http://localhost:3000/admin',
|
||||
newTab: true,
|
||||
children: [{ text: 'Log in to the admin panel' }],
|
||||
},
|
||||
{
|
||||
text: ' and click "preview" to return to this page and view the latest draft content in Next.js preview mode. To make additional changes to the draft, click "save draft" before returning to the preview.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
30
examples/draft-preview/payload/src/seed/pageDraft.ts
Normal file
30
examples/draft-preview/payload/src/seed/pageDraft.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Page } from '../payload-types'
|
||||
|
||||
export const examplePageDraft: Partial<Page> = {
|
||||
richText: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
text: 'This page is an example page with two versions, draft and published. You are currently seeing ',
|
||||
},
|
||||
{
|
||||
text: 'draft',
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
text: ' content because you in preview mode. ',
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
linkType: 'custom',
|
||||
url: 'http://localhost:3000/admin/logout',
|
||||
newTab: true,
|
||||
children: [{ text: 'Log out' }],
|
||||
},
|
||||
{
|
||||
text: ' or click "exit preview mode" from the Payload Admin Bar to see the latest published content. To make additional changes to the draft, click "save draft" before returning to the preview.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
37
examples/draft-preview/payload/src/server.ts
Normal file
37
examples/draft-preview/payload/src/server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '../.env'),
|
||||
})
|
||||
|
||||
import express from 'express'
|
||||
import payload from 'payload'
|
||||
|
||||
import { seed } from './seed'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.get('/', (_, res) => {
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
mongoURL: process.env.MONGODB_URI,
|
||||
express: app,
|
||||
onInit: () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.PAYLOAD_PUBLIC_SEED === 'true') {
|
||||
payload.logger.info('---- SEEDING DATABASE ----')
|
||||
await seed(payload)
|
||||
}
|
||||
|
||||
app.listen(3000)
|
||||
}
|
||||
|
||||
start()
|
||||
34
examples/draft-preview/payload/src/utilities/deepMerge.ts
Normal file
34
examples/draft-preview/payload/src/utilities/deepMerge.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* Simple object check.
|
||||
* @param item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isObject(item: unknown): boolean {
|
||||
return item && typeof item === 'object' && !Array.isArray(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects.
|
||||
* @param target
|
||||
* @param ...sources
|
||||
*/
|
||||
export default function deepMerge<T, R>(target: T, source: R): T {
|
||||
const output = { ...target }
|
||||
if (isObject(target) && isObject(source)) {
|
||||
Object.keys(source).forEach(key => {
|
||||
if (isObject(source[key])) {
|
||||
if (!(key in target)) {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
} else {
|
||||
output[key] = deepMerge(target[key], source[key])
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
34
examples/draft-preview/payload/tsconfig.json
Normal file
34
examples/draft-preview/payload/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"payload/generated-types": ["./src/payload-types.ts"],
|
||||
"node_modules/*": ["./node_modules/*"]
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
],
|
||||
"ts-node": {
|
||||
"transpileOnly": true
|
||||
}
|
||||
}
|
||||
7972
examples/draft-preview/payload/yarn.lock
Normal file
7972
examples/draft-preview/payload/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user