diff --git a/examples/nested-docs/next-app/.env.example b/examples/nested-docs/next-app/.env.example new file mode 100644 index 000000000..c72201c91 --- /dev/null +++ b/examples/nested-docs/next-app/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_PAYLOAD_URL=http://localhost:3000 diff --git a/examples/nested-docs/next-app/.eslintrc.js b/examples/nested-docs/next-app/.eslintrc.js new file mode 100644 index 000000000..b22424b3e --- /dev/null +++ b/examples/nested-docs/next-app/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['plugin:@next/next/recommended', '@payloadcms'], + rules: { + 'import/extensions': 'off', + }, +} diff --git a/examples/nested-docs/next-app/.gitignore b/examples/nested-docs/next-app/.gitignore new file mode 100644 index 000000000..233d5a4d0 --- /dev/null +++ b/examples/nested-docs/next-app/.gitignore @@ -0,0 +1,6 @@ +.next +dist +build +node_modules +.env +package-lock.json diff --git a/examples/nested-docs/next-app/.prettierrc.js b/examples/nested-docs/next-app/.prettierrc.js new file mode 100644 index 000000000..61df3839e --- /dev/null +++ b/examples/nested-docs/next-app/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + printWidth: 100, + parser: 'typescript', + semi: false, + singleQuote: true, + trailingComma: 'all', + arrowParens: 'avoid', +} diff --git a/examples/nested-docs/next-app/README.md b/examples/nested-docs/next-app/README.md new file mode 100644 index 000000000..4dcf5cca8 --- /dev/null +++ b/examples/nested-docs/next-app/README.md @@ -0,0 +1,37 @@ +# Payload Nested Docs 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 [Nested Docs Example](https://github.com/payloadcms/payload/tree/main/examples/nested-docs/payload). + +> 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/main/examples/nested-docs/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/main/examples/nested-docs/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 [Nested Docs Example](https://github.com/payloadcms/payload/tree/main/examples/nested-docs/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/main/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. diff --git a/examples/nested-docs/next-app/app/[...slug]/index.module.scss b/examples/nested-docs/next-app/app/[...slug]/index.module.scss new file mode 100644 index 000000000..3ae99a675 --- /dev/null +++ b/examples/nested-docs/next-app/app/[...slug]/index.module.scss @@ -0,0 +1,3 @@ +.page { + margin-top: calc(var(--base) * 2); +} diff --git a/examples/nested-docs/next-app/app/[...slug]/page.tsx b/examples/nested-docs/next-app/app/[...slug]/page.tsx new file mode 100644 index 000000000..0048fd5fc --- /dev/null +++ b/examples/nested-docs/next-app/app/[...slug]/page.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { notFound } from 'next/navigation' + +import { Page } from '../../payload-types' +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 }) => ( +
+ +

{page?.title}

+ +
+
+) + +export default async function Page({ params }: PageParams) { + let { slug } = params || {} + if (!slug) slug = ['home'] + + const lastSlug = slug[slug.length - 1] + + const page: Page = await fetch( + `${ + process.env.NEXT_PUBLIC_PAYLOAD_URL + }/api/pages?where[slug][equals]=${lastSlug.toLowerCase()}&depth=1`, + )?.then(res => res.json()?.then(data => data.docs[0])) + + if (!page) { + return notFound() + } + + return +} + +type Path = { + slug: string[] +} + +type Paths = Path[] + +export async function generateStaticParams() { + let paths: Paths = [] + + const pages: Page[] = await fetch( + `${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages?depth=0&limit=300`, + )?.then(res => res.json()?.then(data => data.docs)) + + if (pages && Array.isArray(pages) && pages.length > 0) { + paths = pages.map(page => { + const { slug, breadcrumbs } = page + + let slugs = [slug] + + const hasBreadcrumbs = breadcrumbs && Array.isArray(breadcrumbs) && breadcrumbs.length > 0 + + if (hasBreadcrumbs) { + slugs = breadcrumbs + .map(crumb => { + const { url } = crumb + let slug: string = '' + + if (url) { + const split = url.split('/') + slug = split[split.length - 1] + } + + return slug + }) + ?.filter(Boolean) + } + + return { slug: slugs } + }) + } + + return paths +} diff --git a/examples/nested-docs/next-app/app/_components/AdminBar/index.client.tsx b/examples/nested-docs/next-app/app/_components/AdminBar/index.client.tsx new file mode 100644 index 000000000..8001a9dba --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/AdminBar/index.client.tsx @@ -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 = () => Dashboard + +export const AdminBarClient: React.FC = props => { + const [user, setUser] = useState() + + return ( +
+ + } + cmsURL={process.env.NEXT_PUBLIC_PAYLOAD_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', + }} + /> + +
+ ) +} diff --git a/examples/nested-docs/next-app/app/_components/AdminBar/index.module.scss b/examples/nested-docs/next-app/app/_components/AdminBar/index.module.scss new file mode 100644 index 000000000..ad9516097 --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/AdminBar/index.module.scss @@ -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; +} diff --git a/examples/nested-docs/next-app/app/_components/AdminBar/index.tsx b/examples/nested-docs/next-app/app/_components/AdminBar/index.tsx new file mode 100644 index 000000000..4386fb7d3 --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/AdminBar/index.tsx @@ -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 ( + + ) +} diff --git a/examples/nested-docs/next-app/app/_components/Button/index.module.scss b/examples/nested-docs/next-app/app/_components/Button/index.module.scss new file mode 100644 index 000000000..c43dbc192 --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/Button/index.module.scss @@ -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; +} diff --git a/examples/nested-docs/next-app/app/_components/Button/index.tsx b/examples/nested-docs/next-app/app/_components/Button/index.tsx new file mode 100644 index 000000000..2af5cf422 --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/Button/index.tsx @@ -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 | null + newTab?: boolean | null + className?: string + type?: 'submit' | 'button' + disabled?: boolean +} + +export const Button: React.FC = ({ + 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 = ( +
+ {/* */} + {label} +
+ ) + + if (onClick || type === 'submit') el = 'button' + + if (el === 'link') { + return ( + + {content} + + ) + } + + const Element: ElementType = el + + return ( + + {content} + + ) +} diff --git a/examples/nested-docs/next-app/app/_components/CMSLink/index.tsx b/examples/nested-docs/next-app/app/_components/CMSLink/index.tsx new file mode 100644 index 000000000..fbb91ae36 --- /dev/null +++ b/examples/nested-docs/next-app/app/_components/CMSLink/index.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import Link from 'next/link' + +import { Page } from '../../../payload-types' +import { Button } from '../Button' + +export type CMSLinkType = { + type?: 'custom' | 'reference' | null + url?: string | null + newTab?: boolean | null + reference?: { + value: string | Page + relationTo: 'pages' + } | null + label?: string + appearance?: 'default' | 'primary' | 'secondary' + children?: React.ReactNode + className?: string +} + +export const CMSLink: React.FC = ({ + type, + url, + newTab, + reference, + label, + appearance, + children, + className, +}) => { + let href = url + + if (type === 'reference' && reference && reference.value && typeof reference.value === 'object') { + if ('breadcrumbs' in reference.value) { + href = reference.value.breadcrumbs?.[reference.value.breadcrumbs.length - 1]?.url || '' + } else { + href = `/${reference.value.slug === 'home' ? '' : reference.value.slug}` + } + } + + if (!appearance) { + const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {} + + if (type === 'custom') { + return ( + + {label && label} + {children && children} + + ) + } + + if (href) { + return ( + + {label && label} + {children && children} + + ) + } + } + + const buttonProps = { + newTab, + href, + appearance, + label, + } + + return