chore(examples): misc improvements to the draft preview example (#10876)

There were a number of things wrong or could have been improved with the
[Draft Preview
Example](https://github.com/payloadcms/payload/tree/main/examples/draft-preview),
namely:

- The package.json was missing `"type": "modue"` which would throw ESM
related import errors on startup
- The preview secret was missing entirely, with pointless logic was
written to throw an error if it missing in the search params as opposed
to not matching the environment secret
- The `/next/exit-preview` route was duplicated twice
- The preview endpoint was unnecessarily querying the database for a
matching document as opposed to letting the underlying page itself 404
as needed, and it was also throwing an inaccurate error message

Some less critical changes were:
- The page query was missing the `depth` and `limit` parameters which is
best practice to optimize performance
- The logic to format search params in the preview URL was unnecessarily
complex
- Utilities like `generatePreviewPath` and `getGlobals` were
unnecessarily obfuscating simple functions
- The `/preview` and `/exit-preview` routes were unecessarily nested
within a `/next` page segment
- Payload types weren't aliased
This commit is contained in:
Jacob Fletcher
2025-01-29 23:14:08 -05:00
committed by GitHub
parent 8f27f85023
commit 2b9ee62fc0
22 changed files with 1288 additions and 1343 deletions

View File

@@ -3,7 +3,7 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import React, { cache, Fragment } from 'react'
import type { Page as PageType } from '../../../payload-types'
import type { Page as PageType } from '@payload-types'
import { Gutter } from '../../../components/Gutter'
import RichText from '../../../components/RichText'
@@ -13,6 +13,7 @@ import classes from './index.module.scss'
export async function generateStaticParams() {
const payload = await getPayload({ config })
const pages = await payload.find({
collection: 'pages',
draft: false,

View File

@@ -1,7 +0,0 @@
import { draftMode } from 'next/headers'
export async function GET(): Promise<Response> {
const draft = await draftMode()
draft.disable()
return new Response('Draft mode is disabled')
}

View File

@@ -1,94 +0,0 @@
import type { CollectionSlug, PayloadRequest } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import configPromise from '../../../../payload.config'
const payloadToken = 'payload-token'
export async function GET(
req: {
cookies: {
get: (name: string) => {
value: string
}
}
} & Request,
): Promise<Response> {
const payload = await getPayload({ config: configPromise })
const token = req.cookies.get(payloadToken)?.value
const { searchParams } = new URL(req.url)
const path = searchParams.get('path')
const collection = searchParams.get('collection') as CollectionSlug
const slug = searchParams.get('slug')
const previewSecret = searchParams.get('previewSecret')
if (previewSecret) {
return new Response('You are not allowed to preview this page', { status: 403 })
} else {
if (!path) {
return new Response('No path provided', { status: 404 })
}
if (!collection) {
return new Response('No path provided', { status: 404 })
}
if (!slug) {
return new Response('No path provided', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for internal previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
// You can add additional checks here to see if the user is allowed to preview this page
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// Verify the given slug exists
try {
const docs = await payload.find({
collection,
draft: true,
where: {
slug: {
equals: slug,
},
},
})
if (!docs.docs.length) {
return new Response('Document not found', { status: 404 })
}
} catch (error) {
payload.logger.error({
err: error,
msg: 'Error verifying token for live preview:',
})
}
draft.enable()
redirect(path)
}
}

View File

@@ -0,0 +1,63 @@
import type { CollectionSlug, PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import configPromise from '@payload-config'
export async function GET(
req: {
cookies: {
get: (name: string) => {
value: string
}
}
} & Request,
): Promise<Response> {
const payload = await getPayload({ config: configPromise })
const { searchParams } = new URL(req.url)
const path = searchParams.get('path')
const collection = searchParams.get('collection') as CollectionSlug
const slug = searchParams.get('slug')
const previewSecret = searchParams.get('previewSecret')
if (previewSecret !== process.env.PREVIEW_SECRET) {
return new Response('You are not allowed to preview this page', { status: 403 })
}
if (!path || !collection || !slug) {
return new Response('Insufficient search params', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for relative previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// You can add additional checks here to see if the user is allowed to preview this page
draft.enable()
redirect(path)
}

View File

@@ -2,7 +2,7 @@ import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath } from 'next/cache'
import type { Page } from '../../../payload-types'
import type { Page } from '@payload-types'
export const revalidatePage: CollectionAfterChangeHook<Page> = ({ doc, previousDoc, req }) => {
if (req.context.skipRevalidate) {

View File

@@ -1,7 +1,6 @@
import type { CollectionConfig } from 'payload'
import type { CollectionConfig, CollectionSlug } from 'payload'
import richText from '../../fields/richText'
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { loggedIn } from './access/loggedIn'
import { publishedOrLoggedIn } from './access/publishedOrLoggedIn'
import { formatSlug } from './hooks/formatSlug'
@@ -17,12 +16,15 @@ export const Pages: CollectionConfig = {
},
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
preview: (doc) => {
const path = generatePreviewPath({
slug: typeof doc?.slug === 'string' ? doc.slug : '',
collection: 'pages',
preview: ({ slug, collection }: { slug: string; collection: CollectionSlug }) => {
const encodedParams = new URLSearchParams({
slug,
collection,
path: `/${slug}`,
previewSecret: process.env.PREVIEW_SECRET || '',
})
return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}`
return `${process.env.NEXT_PUBLIC_SERVER_URL}/preview?${encodedParams.toString()}`
},
useAsTitle: 'title',
},

View File

@@ -1,7 +1,7 @@
import Link from 'next/link'
import React from 'react'
import type { Page } from '../../payload-types'
import type { Page } from '@payload-types'
import { Button } from '../Button'

View File

@@ -1,16 +1,22 @@
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import type { MainMenu } from '../../payload-types'
import type { MainMenu } from '@payload-types'
import { getCachedGlobal } from '../../utilities/getGlobals'
import { CMSLink } from '../CMSLink'
import { Gutter } from '../Gutter'
import classes from './index.module.scss'
export async function Header() {
const header: MainMenu = await getCachedGlobal('main-menu', 1)()
const payload = await getPayload({ config: configPromise })
const header: MainMenu = await payload.findGlobal({
slug: 'main-menu',
depth: 1,
})
const navItems = header?.navItems || []

View File

@@ -1,4 +1,4 @@
import type { Page } from '../payload-types'
import type { Page } from '@payload-types'
// Used for pre-seeded content so that the homepage is not empty
// @ts-expect-error: Page type is not fully compatible with the provided object structure

View File

@@ -1,4 +1,4 @@
import type { Page } from '../payload-types'
import type { Page } from '@payload-types'
export const examplePage: Partial<Page> = {
slug: 'example-page',

View File

@@ -1,4 +1,4 @@
import type { Page } from '../payload-types'
import type { Page } from '@payload-types'
export const examplePageDraft: Partial<Page> = {
richText: [

View File

@@ -1,28 +0,0 @@
import type { CollectionSlug } from 'payload'
const collectionPrefixMap: Partial<Record<CollectionSlug, string>> = {
pages: '',
}
type Props = {
collection: keyof typeof collectionPrefixMap
slug: string
}
export const generatePreviewPath = ({ slug, collection }: Props) => {
const path = `${collectionPrefixMap[collection]}/${slug}`
const params = {
slug,
collection,
path,
}
const encodedParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
encodedParams.append(key, value)
})
return `/next/preview?${encodedParams.toString()}`
}

View File

@@ -1,27 +0,0 @@
import type { Config } from 'src/payload-types'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import configPromise from '../payload.config'
type Global = keyof Config['globals']
async function getGlobal(slug: Global, depth = 0) {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug,
depth,
})
return global
}
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedGlobal = (slug: Global, depth = 0) =>
unstable_cache(async () => getGlobal(slug, depth), [slug], {
tags: [`global_${slug}`],
})