Add block editing
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
tobias 2024-06-23 22:23:06 +02:00
parent f9ee909e01
commit 60f7a4e4f2
32 changed files with 1061 additions and 256 deletions

View File

@ -23,8 +23,10 @@
"dependencies": {
"@payloadcms/db-mongodb": "3.0.0-beta.40",
"@payloadcms/next": "3.0.0-beta.40",
"@payloadcms/plugin-nested-docs": "3.0.0-beta.35",
"@payloadcms/richtext-lexical": "3.0.0-beta.40",
"@payloadcms/ui": "3.0.0-beta.40",
"babel-plugin-react-compiler": "^0.0.0-experimental-592953e-20240517",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"next": "15.0.0-rc.0",
@ -32,7 +34,7 @@
"react": "19.0.0-rc-f994737d14-20240522",
"react-dom": "19.0.0-rc-f994737d14-20240522",
"sharp": "0.32.6",
"babel-plugin-react-compiler": "^0.0.0-experimental-592953e-20240517"
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^20.12.12",

View File

@ -27,6 +27,7 @@ import Media from '@payload/collections/Media'
import Authors from '@payload/collections/Authors'
import Posts from '@payload/collections/Posts'
import Users from '@payload/collections/Users'
import Pages from '@payload/collections/Pages'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -40,28 +41,7 @@ export default buildConfig({
},
},
editor: lexicalEditor(),
collections: [
Users,
Posts,
Authors,
Media,
{
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText',
},
],
},
],
collections: [Users, Posts, Authors, Media, Pages],
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'types/payload-types.ts'),

View File

View File

@ -1,191 +0,0 @@
* {
box-sizing: border-box;
}
html {
width: 100%;
height: 100%;
background-color: #000000;
}
body {
color: #ffffff;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0 20px;
}
main {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
min-height: 100vh;
max-width: 800px;
margin: 0 auto;
padding-block: 80px;
border-inline-width: 1px;
border-inline-style: solid;
border-image: linear-gradient(180deg, #ffffff00, #ffffff00, #ffffff1a, #ffffff00) 1;
@media screen and (max-width: 600px) {
padding-block: 20px;
}
}
article {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
padding: 60px 80px;
@media screen and (max-width: 600px) {
padding: 40px 40px;
}
}
.badge {
display: flex;
align-items: center;
gap: 10px;
color: #fff;
font-size: 14px;
font-style: normal;
font-weight: 300;
text-transform: uppercase;
letter-spacing: 2.6px;
}
h1 {
color: #ffffff;
font-size: 4rem;
font-weight: 600;
line-height: normal;
letter-spacing: -0.02rem;
margin: 0;
@media screen and (max-width: 600px) {
font-size: 2rem;
}
}
p {
color: #ffffff;
font-size: 16px;
font-weight: 300;
line-height: 28px;
margin: 0;
}
a {
color: #ffffff;
text-decoration: underline;
transition: color 0.2s ease-out;
&:hover {
color: #ffffff80;
}
}
.codeBlock {
position: relative;
background-color: #00000066;
margin: 0;
padding: 0;
pre {
margin: 0;
padding: 60px 80px;
overflow-x: auto;
@media screen and (max-width: 600px) {
padding: 40px 40px;
}
&::before {
content: '';
display: block;
position: absolute;
top: 0;
left: calc(50% - 50vw);
width: 100vw;
height: 1px;
background: linear-gradient(90deg, #ffffff00, #ffffff1a, #ffffff1a, #ffffff00);
z-index: 1;
}
&::after {
content: url('/crosshair.svg');
display: block;
height: 19px;
width: 19px;
position: absolute;
top: -9px;
left: -10px;
}
}
code {
font-size: 14px;
line-height: 2;
}
&::before {
content: '';
display: block;
position: absolute;
bottom: 0;
left: calc(50% - 50vw);
width: 100vw;
height: 1px;
background: linear-gradient(90deg, #ffffff00, #ffffff1a, #ffffff1a, #ffffff00);
z-index: 1;
}
&::after {
content: url('/crosshair.svg');
display: block;
height: 19px;
width: 19px;
position: absolute;
bottom: -9px;
right: -10px;
}
}
.background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
div.blur {
display: block;
position: absolute;
width: 100%;
height: 100%;
background: url('/blur.png');
background-repeat: repeat;
background-size: 400px 400px;
background-blend-mode: soft-light, normal;
backdrop-filter: blur(60px);
}
div.gradient {
display: block;
position: absolute;
width: 100%;
height: 100%;
background: url('/gradient.webp');
background-size: cover;
background-position: center;
z-index: -2;
}
}

View File

@ -1,16 +1,23 @@
import React from 'react'
import './globals.scss'
import { Inter } from 'next/font/google'
import './globals.css'
/* import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
*/
/* Our app sits here to not cause any conflicts with payload's root layout */
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
{
/* <html className={inter.className}>
<body>{children}</body>
</html>
*/
}
return (
<html className={inter.className}>
<html>
<body>{children}</body>
</html>
)

View File

@ -2,49 +2,19 @@ import { Badge } from '@/components/Badge'
import { Background } from '@/components/Background'
import Link from 'next/link'
import React from 'react'
import LexicalContent from '@/components/LexicalContent'
const Page = () => {
/* const url = 'http://localhost:3000/api/pages/66781d2fb752297de17a3368?depth=1&draft=false'
const content = fetch(url)
console.log(content) */
return (
<>
<main>
<article>
<Badge />
<h1>Payload 3.0</h1>
<p>
This BETA is rapidly evolving, you can report any bugs against{' '}
<Link href="https://github.com/payloadcms/payload-3.0-demo/issues" target="_blank">
the repo
</Link>{' '}
or in the{' '}
<Link
href="https://discord.com/channels/967097582721572934/1215659716538273832"
target="_blank"
>
dedicated channel in Discord
</Link>
. Payload is running at <Link href="/admin">/admin</Link>. An example of a custom route
running the Local API can be found at <Link href="/my-route">/my-route</Link>.
</p>
<p>You can use the Local API in your server components like this:</p>
</article>
<div className="codeBlock">
<pre>
<code>
{`import { getPayloadHMR } from '@payloadcms/next/utilities'
import configPromise from '@payload-config'
const payload = await getPayloadHMR({ config: configPromise })
<h1>Lexical content hereinafter</h1>
const data = await payload.find({
collection: 'posts',
})
return <Posts data={data} />
`}
</code>
</pre>
</div>
</main>
<Background />
{/* <LexicalContent lazyLoadImages={false} childrenNodes={content?.root?.children} locale={''} /> */}
</>
)
}

View File

@ -0,0 +1,35 @@
import Blocks from '@/components/Blocks'
import { COLLECTION_SLUG_PAGE } from '@payload/collections/config'
import { getDocument } from '@/utils/getDocument'
import { generateMeta } from '@/utils/generateMeta'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
type PageArgs = {
params: {
path: string[]
}
}
export async function generateMetadata({ params }: PageArgs): Promise<Metadata> {
const page = await getDocument({
collection: COLLECTION_SLUG_PAGE,
path: params.path,
depth: 3,
})
if (!page) notFound()
return generateMeta(params?.path)
}
const Page = async ({ params }: PageArgs) => {
const page = await getDocument({
collection: COLLECTION_SLUG_PAGE,
path: params.path,
depth: 3,
})
if (!page) notFound()
return <Blocks blocks={page?.blocks} locale="en" />
}
export default Page

View File

@ -40,3 +40,15 @@ export const isAdminOrSelf = ({ req: { user } }: any) => {
}
return false
}
export const isAdminOrPublished = ({ req: { user } }: any) => {
if (user && user?.role === 'admin') {
return true
}
return {
_status: {
equals: 'published',
},
}
}

View File

@ -0,0 +1,4 @@
import RichText from '@payload/blocks/rich-text'
// eslint-disable-next-line import/no-anonymous-default-export
export default [RichText]

View File

@ -0,0 +1,14 @@
import type { Block } from 'payload/types'
const RichText: Block = {
slug: 'RichText',
interfaceName: 'RichTextBlock',
fields: [
{
name: 'content',
type: 'richText'
}
]
}
export default RichText

View File

@ -0,0 +1,62 @@
import { CollectionConfig } from 'payload/types'
import { slugField, pathField } from '@payload/fields/'
import { blocksField } from '@payload/fields/blocks'
import { COLLECTION_SLUG_PAGE } from './config'
import { createBreadcrumbsField } from '@payloadcms/plugin-nested-docs'
import { revalidateTag } from 'next/cache'
import { generateDocumentCacheKey } from '@/utils/getDocument'
import { isAdmin, isAdminOrPublished } from '@payload/access/isAdmin'
const Pages: CollectionConfig = {
slug: COLLECTION_SLUG_PAGE,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'path', 'updatedAt', 'createdAt'],
},
versions: {
drafts: {
autosave: false,
},
maxPerDoc: 10,
},
access: {
read: isAdminOrPublished,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
/* hooks: {
afterChange: [
async ({ doc, collection }) => {
revalidateTag(generateDocumentCacheKey(collection.slug, doc.path))
},
],
}, */
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Content',
fields: [
{
type: 'text',
name: 'title',
},
blocksField(),
],
},
],
},
slugField(),
pathField(),
createBreadcrumbsField(COLLECTION_SLUG_PAGE, {
name: 'breadcrumbs',
admin: {
disabled: true,
},
}),
],
}
export default Pages

View File

@ -0,0 +1,10 @@
/* export const COLLECTION_SLUG_USER = 'users' as const
export const COLLECTION_SLUG_SESSIONS = 'sessions' as const
export const COLLECTION_SLUG_FORMS = 'forms' as const
export const COLLECTION_SLUG_MEDIA = 'media' as const
*/
export const COLLECTION_SLUG_PAGE = 'pages' as const
/* export const COLLECTION_SLUG_PRODUCTS = 'products' as const
export const COLLECTION_SLUG_PRICES = 'prices' as const
export const COLLECTION_SLUG_SUBSCRIPTIONS = 'subscriptions' as const */

View File

@ -0,0 +1,16 @@
import type { Field } from 'payload/types'
import blocks from '@payload/blocks'
import deepMerge from 'deepmerge'
type BlocksField = (overrides?: Partial<Field>) => Field
export const blocksField: BlocksField = (overrides) => {
return deepMerge<Field, Partial<Field>>(
{
name: 'blocks',
type: 'blocks',
blocks,
},
overrides || {},
)
}

View File

@ -0,0 +1,26 @@
import deepMerge from 'deepmerge'
import type { Field } from 'payload/types'
import * as HiIcons from 'react-icons/hi2'
const iconOptions = Object.entries(HiIcons)
.filter(([key, value]) => typeof value === 'function')
.map(([key]) => ({
value: key,
label: key.replace(/([a-z])([A-Z])/g, '$1 $2')
}))
type IconField = (overrides?: Partial<Field>) => Field
const iconField: IconField = (overrides = {}) => {
return deepMerge<Field, Partial<Field>>(
{
type: 'select',
name: 'icon',
label: 'Icon',
options: iconOptions
},
overrides
)
}
export default iconField

View File

@ -0,0 +1,2 @@
export { default as pathField } from "./path";
export { default as slugField } from "./slug";

View File

@ -0,0 +1,122 @@
import { COLLECTION_SLUG_PAGE } from '@payload/collections/config'
import generateBreadcrumbsUrl from '@/utils/generateBreadcrumbsUrl'
import { getParents } from '@payloadcms/plugin-nested-docs'
import deepmerge from 'deepmerge'
import { APIError } from 'payload/errors'
import type { Field, Payload, Where } from 'payload/types'
import type { Config } from 'types/payload-types'
import generateRandomString from '@/utils/generateRandomString'
type Collection = keyof Config['collections']
type WillPathConflictParams = {
payload: Payload
path: string
originalDoc?: { id?: string }
collection: Collection
uniquePathFieldCollections?: Collection[]
}
export const willPathConflict = async ({
payload,
path,
originalDoc,
collection,
uniquePathFieldCollections = [],
}: WillPathConflictParams): Promise<boolean> => {
if (!payload || !uniquePathFieldCollections.includes(collection)) return false
const queries = uniquePathFieldCollections.map((targetCollection) => {
const whereCondition: Where = {
path: { equals: path },
}
if (originalDoc?.id && collection === targetCollection) {
whereCondition.id = { not_equals: originalDoc.id }
}
return payload.find({
collection: targetCollection,
where: whereCondition,
limit: 1,
pagination: false,
})
})
const results = await Promise.allSettled(queries)
return results.some((result) => result.status === 'fulfilled' && result.value.docs.length > 0)
}
type GetNewPathParams = {
req: any
collection: Collection
currentDoc: any
operation?: string
}
export async function getNewPath({
req,
collection,
currentDoc,
operation,
}: GetNewPathParams): Promise<string> {
const isAutoSave = operation === 'create' && currentDoc?._status === 'draft'
if (isAutoSave || currentDoc?.slug == null || !collection)
return `/${currentDoc?.id || generateRandomString(20)}`
const newPath = currentDoc?.breadcrumbs?.at(-1)?.url
if (newPath) return newPath
const docs = await getParents(
req,
{ parentFieldSlug: 'parent' } as any,
collection as any,
currentDoc,
[currentDoc],
)
return generateBreadcrumbsUrl(docs, currentDoc)
}
const pathField = (overrides?: Partial<Field>): Field =>
deepmerge<Field, Partial<Field>>(
{
type: 'text',
name: 'path',
unique: true,
index: true,
hooks: {
beforeChange: [
async ({ collection, req, siblingData, originalDoc, operation }) => {
const currentDoc = { ...originalDoc, ...siblingData }
const newPath = await getNewPath({
req,
collection: collection?.slug as Collection,
currentDoc,
operation,
})
const isNewPathConflicting = await willPathConflict({
payload: req.payload,
path: newPath,
originalDoc,
collection: collection ? (collection.slug as Collection) : COLLECTION_SLUG_PAGE,
uniquePathFieldCollections: [COLLECTION_SLUG_PAGE], // Add more collections as needed
})
if (isNewPathConflicting) {
const error = new APIError(
'This will create a conflict with an existing path.',
400,
[{ field: 'slug', message: 'This will create a conflict with an existing path.' }],
false,
)
throw error
}
return newPath
},
],
},
admin: {
position: 'sidebar',
readOnly: true,
},
},
overrides || {},
)
export default pathField

View File

@ -0,0 +1,49 @@
import type { Field, FieldHook } from 'payload/types'
import deepMerge from 'deepmerge'
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' && value.length > 0) {
return format(value)
}
if (operation === 'create') {
const fallbackData = (data && data[fallback]) || (originalDoc && originalDoc[fallback])
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData)
}
}
return value
}
type Slug = (fieldToUse?: string, overrides?: Partial<Field>) => Field
const slugField: Slug = (fieldToUse = 'title', overrides = {}) => {
return deepMerge<Field, Partial<Field>>(
{
name: 'slug',
label: 'Slug',
type: 'text',
index: true,
required: false, // Need to be false so that we can use beforeValidate hook to set slug.
admin: {
position: 'sidebar'
},
hooks: {
beforeValidate: [formatSlug(fieldToUse)]
}
},
overrides
)
}
export default slugField

View File

@ -0,0 +1,22 @@
import type { AdditionalBlockProps } from '@/components/Blocks'
import Container from '@/components/Container'
import LexicalContent from '@/components/LexicalContent'
import type { RichTextBlock } from 'types/payload-types'
export default function RichText({ content, locale }: RichTextBlock & AdditionalBlockProps) {
if (content?.root?.children?.length === 0) return null
return (
<section className="py-10 first:mt-16">
<Container>
<div className="prose dark:prose-invert md:prose-lg">
<LexicalContent
// @ts-ignore
childrenNodes={content?.root?.children}
locale={locale}
lazyLoadImages={false}
/>
</div>
</Container>
</section>
)
}

View File

@ -0,0 +1,31 @@
import Author from './Author'
export type AdditionalBlockProps = {
blockIndex: number
locale: string
}
const blockComponents = {
Author: Author,
}
const Blocks = ({ blocks, locale }: any) => {
return (
<>
{blocks
?.filter(
(block: any) =>
block && block.blockType && blockComponents.hasOwnProperty(block.blockType),
)
.map((block: any, ix: number) => {
// @ts-ignore
const BlockComponent = blockComponents[block.blockType] ?? null
return BlockComponent ? (
<BlockComponent key={ix} {...block} blockIndex={ix} locale={locale} />
) : null
})}
</>
)
}
export default Blocks

10
src/components/Container.tsx Executable file
View File

@ -0,0 +1,10 @@
import cn from '@/utils/cn'
import type { ComponentPropsWithoutRef } from 'react'
const Container = ({ children, className }: ComponentPropsWithoutRef<'div'>) => {
return (
<div className={cn('container mx-auto w-full max-w-screen-lg px-3', className)}>{children}</div>
)
}
export default Container

View File

@ -0,0 +1,51 @@
//This copy-and-pasted from somewhere in lexical here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts
// DOM
export const DOM_ELEMENT_TYPE = 1
export const DOM_TEXT_TYPE = 3
// Reconciling
export const NO_DIRTY_NODES = 0
export const HAS_DIRTY_NODES = 1
export const FULL_RECONCILE = 2
// Text node modes
export const IS_NORMAL = 0
export const IS_TOKEN = 1
export const IS_SEGMENTED = 2
// IS_INERT = 3
// Text node formatting
export const IS_BOLD = 1
export const IS_ITALIC = 1 << 1
export const IS_STRIKETHROUGH = 1 << 2
export const IS_UNDERLINE = 1 << 3
export const IS_CODE = 1 << 4
export const IS_SUBSCRIPT = 1 << 5
export const IS_SUPERSCRIPT = 1 << 6
export const IS_HIGHLIGHT = 1 << 7
export const IS_ALL_FORMATTING = IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | IS_HIGHLIGHT
export const IS_DIRECTIONLESS = 1
export const IS_UNMERGEABLE = 1 << 1
// Element node formatting
export const IS_ALIGN_LEFT = 1
export const IS_ALIGN_CENTER = 2
export const IS_ALIGN_RIGHT = 3
export const IS_ALIGN_JUSTIFY = 4
export const IS_ALIGN_START = 5
export const IS_ALIGN_END = 6
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
bold: IS_BOLD,
code: IS_CODE,
italic: IS_ITALIC,
strikethrough: IS_STRIKETHROUGH,
subscript: IS_SUBSCRIPT,
superscript: IS_SUPERSCRIPT,
underline: IS_UNDERLINE
}
export type TextFormatType = 'bold' | 'underline' | 'strikethrough' | 'italic' | 'code' | 'subscript' | 'superscript'

View File

@ -0,0 +1,213 @@
/* eslint-disable react/no-children-prop */
import normalizePath from '@/utils/normalizePath'
import clsx from 'clsx'
import Link from 'next/link'
import React, { CSSProperties, type FC, type ReactElement } from 'react'
import Image from 'next/image'
import {
IS_BOLD,
IS_CODE,
IS_ITALIC,
IS_STRIKETHROUGH,
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_UNDERLINE,
} from './RichTextNodeFormat'
type SerializedLexicalNode = {
children?: SerializedLexicalNode[]
direction: string
format: number
indent?: string | number
type: string
version: number
style?: string
mode?: string
text?: string
[other: string]: any
}
type TextComponentProps = {
children: ReactElement | string
format: number
}
const getLinkForDocument = (doc: any, locale?: string): string => {
let path = doc?.path
if (!path || path.startsWith('/home') || path === '/' || path === '') path = '/'
return normalizePath(`/${locale}${path}`)
}
function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b)
}
function calculateAspectRatio(width: number, height: number): string {
const divisor = gcd(width, height)
const simplifiedWidth = width / divisor
const simplifiedHeight = height / divisor
return `${simplifiedWidth}x${simplifiedHeight}`
}
const TextComponent: FC<TextComponentProps> = ({ children, format }) => {
const formatFunctions: { [key: number]: (child: ReactElement | string) => ReactElement } = {
[IS_BOLD]: (child) => <strong>{child}</strong>,
[IS_ITALIC]: (child) => <em>{child}</em>,
[IS_STRIKETHROUGH]: (child) => <del>{child}</del>,
[IS_UNDERLINE]: (child) => <u>{child}</u>,
[IS_CODE]: (child) => <code>{child}</code>,
[IS_SUBSCRIPT]: (child) => <sub>{child}</sub>,
[IS_SUPERSCRIPT]: (child) => <sup>{child}</sup>,
}
const formattedText = Object.entries(formatFunctions).reduce(
(formattedText, [key, formatter]) => {
return format & Number(key) ? formatter(formattedText) : formattedText
},
children,
)
return <>{formattedText}</>
}
const SerializedLink: React.FC<{
node: SerializedLexicalNode
locale: string
children: JSX.Element | null
}> = ({ node, locale, children }) => {
const { doc, url, newTab, linkType } = node.fields as any
const document = doc?.value
const href = linkType === 'custom' ? url : getLinkForDocument(document, locale)
const target = newTab ? '_blank' : undefined
return (
<Link href={href} target={target}>
{children}
</Link>
)
}
const getNodeClassNames = (node: SerializedLexicalNode) => {
const attributes: Record<string, any> = {}
if (!node) return attributes
let classNames = ''
if (String(node?.format).toString()?.includes('left') && node.direction !== 'ltr')
classNames += 'text-left '
if (String(node?.format).toString()?.includes('center')) classNames += 'text-center '
if (String(node?.format).toString()?.includes('right') && node.direction !== 'rtl')
classNames += 'text-right '
if (classNames.length > 0) attributes.className = classNames.trim()
const indent = parseInt(`${node?.indent || 0}`)
if (!isNaN(indent) && indent !== 0) {
attributes.style = { '--indent': `${indent * 10}px` } as CSSProperties
attributes.className = `${attributes.className ?? ''} ml-[--indent]`.trim()
}
return attributes
}
const LexicalContent: React.FC<{
childrenNodes: SerializedLexicalNode[]
locale: string
className?: string
lazyLoadImages: boolean
}> = ({ childrenNodes, locale, lazyLoadImages = false }) => {
if (!Array.isArray(childrenNodes)) return null
const renderedChildren = childrenNodes.map((node, ix) => {
if (!node) return null
const attributes = getNodeClassNames(node || '')
if (node.type === 'text') {
return (
<TextComponent key={ix} format={node.format}>
<>
{Object.keys(attributes).length > 0 && <span {...attributes}>{node?.text || ''}</span>}
{(Object.keys(attributes).length === 0 && node?.text) || ''}
</>
</TextComponent>
)
}
const serializedChildren = node.children ? (
<LexicalContent
key={ix}
childrenNodes={node.children}
locale={locale}
lazyLoadImages={lazyLoadImages}
/>
) : null
switch (node.type) {
case 'linebreak':
return <br key={ix} />
case 'link':
return <SerializedLink key={ix} node={node} locale={locale} children={serializedChildren} />
case 'list':
const ListTag = node.listType === 'bullet' ? 'ul' : 'ol'
attributes.className = clsx(
attributes.className,
'mb-4 pl-8',
ListTag === 'ol' ? 'list-decimal' : 'list-disc',
)
return (
<ListTag key={ix} {...attributes}>
{serializedChildren}
</ListTag>
)
case 'listitem':
return (
<li key={ix} {...attributes}>
{serializedChildren}
</li>
)
case 'heading':
const HeadingTag = node.tag as keyof JSX.IntrinsicElements
return (
<HeadingTag key={ix} {...attributes}>
{serializedChildren}
</HeadingTag>
)
case 'quote':
return (
<blockquote key={ix} {...attributes}>
{serializedChildren}
</blockquote>
)
case 'upload':
const upload = node?.value
if (!upload) return null
const imageAspectRatio = calculateAspectRatio(upload.width, upload.height)
return (
<Image
key={ix}
width={upload.width}
height={upload.height}
src={upload?.url}
loading={lazyLoadImages ? 'lazy' : 'eager'}
fetchPriority={lazyLoadImages ? 'low' : 'high'}
sizes="(max-width: 768px) 65ch, 100vw"
className="max-w-[calc(100%+40px)] translate-x-[-20px]"
alt={upload?.alt || upload.filename}
/>
)
default:
if (
Array.isArray(serializedChildren?.props?.childrenNodes) &&
serializedChildren?.props?.childrenNodes.length === 0
)
return <br key={ix} />
return (
<p key={ix} {...attributes}>
{serializedChildren}
</p>
)
}
})
return <>{renderedChildren.filter((node) => node !== null)}</>
}
export default LexicalContent

View File

@ -0,0 +1,18 @@
'use server'
import { SESSION_STRATEGY } from '@/lib/auth/config'
import { COLLECTION_SLUG_SESSIONS } from '@/payload/collections/config'
import { revalidateTag } from 'next/cache'
import type { Payload } from 'payload'
import type { User } from '~/payload-types'
export const revalidateUser = async (user: User, payload: Payload) => {
revalidateTag(`payload-user-${user.id}`)
revalidateTag(`payload-user-email-${user.email}`)
if (SESSION_STRATEGY === 'database') {
const { docs: sessions } = await (await payload).find({ where: { user: { equals: user.id } }, collection: COLLECTION_SLUG_SESSIONS })
sessions.forEach((session) => {
revalidateTag(`payload-user-session-${session.sessionToken}`)
})
}
}

21
src/lib/payload/index.ts Normal file
View File

@ -0,0 +1,21 @@
import configPromise from '@payload-config'
import { getPayloadHMR as getPayloadInstance } from '@payloadcms/next/utilities'
import { headers as getHeaders } from 'next/headers'
import type { User } from '~/payload-types'
export async function getPayload(): ReturnType<typeof getPayloadInstance> {
return getPayloadInstance({ config: await configPromise })
}
/**
* Get the current user with out needing to import the payload instance & headers.
*
* @description The difference between this function and the one in the auth/edge.ts file is that here we get
* payload instance, just to make other parts of you code cleaner. We can't get the payload instance in the
* auth/edge.ts file because that could cause a import loop.
*/
export async function getCurrentUser(): Promise<User | null> {
const headers = getHeaders()
const payload = await getPayload()
return (await payload.auth({ headers })).user
}

6
src/utils/cn.ts Executable file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export default function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,7 @@
export default function generateBreadcrumbsUrl(docs: any, lastDoc: any) {
let prefix = ''
// You might want different prefixes for different collections.
switch (lastDoc._collection) {
}
return docs.reduce((url: any, doc: any) => `${url}/${doc.slug ?? ''}`, prefix)
}

37
src/utils/generateMeta.ts Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from 'next'
import _get from 'lodash/get'
import deepmerge from 'deepmerge'
import { getDocument } from '@/utils/getDocument'
import normalizePath from './normalizePath'
const defaultTitle = 'Payload SaaS Starter'
const defaultDescription = 'An open-source website built with Payload and Next.js.'
const siteName = 'Payload SaaS Starter'
const defaultOpenGraph: Metadata['openGraph'] = {
type: 'website',
description: defaultDescription,
siteName,
title: defaultTitle,
}
export const generateMeta = async (path: string | string[]): Promise<Metadata> => {
const doc = await getDocument({
collection: 'pages',
path,
depth: 1,
})
const metaTitle = _get(doc, 'meta.title', null)
const title = metaTitle ?? (_get(doc, 'title', defaultTitle) as string)
const description = _get(doc, 'meta.description', defaultDescription)
const ogImage = _get(doc, 'meta.image.url', `/api/og${normalizePath(path, true)}image.jpg`)
return {
description,
openGraph: deepmerge(defaultOpenGraph, {
description,
images: ogImage ? [{ url: ogImage }] : undefined,
}),
title: { absolute: title },
}
}

View File

@ -0,0 +1,13 @@
export default function generateRandomString(
length: number,
characters: string = 'abcdefghijklmnopqrstuvwxyz0123456789',
): string {
let result = ''
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}

80
src/utils/getDocument.ts Normal file
View File

@ -0,0 +1,80 @@
import type { Config } from 'types/payload-types'
import { unstable_cache } from 'next/cache'
import normalizePath from '@/utils/normalizePath'
import { getCurrentUser, getPayload } from '@/lib/payload'
import { draftMode } from 'next/headers'
type Collection = keyof Config['collections'] | string
type Path = string | string[]
type CacheOption = 'noDraft' | true | false
export const generateDocumentCacheKey = (collection: Collection, path?: Path): string => {
return `${collection}_path_${normalizePath(path, false)}`
}
export const generateDocumentCacheParams = (collection: Collection, path?: Path): string[] => {
return [collection, normalizePath(path)]
}
export const conditionalCache = async <T>(
fetchFunction: () => Promise<T>,
cacheKey: string,
cache: boolean | undefined,
revalidate: false | number | undefined,
): Promise<T> => {
if (!cache) {
return fetchFunction()
}
return unstable_cache(fetchFunction, [cacheKey], { revalidate, tags: [cacheKey] })()
}
interface GetDocumentParams<K extends keyof Config['collections']> {
collection: K
path?: Path
depth?: number
cache?: CacheOption
revalidate?: false | number | undefined
}
export const getDocument = async <K extends keyof Config['collections']>({
collection,
path,
depth = 0,
cache = 'noDraft',
revalidate = false,
}: GetDocumentParams<K>): Promise<Config['collections'][K] | null> => {
const { isEnabled: draft } = draftMode()
const payload = await getPayload()
const user = draft ? await getCurrentUser() : null
const normalizedPath = normalizePath(path, false)
const where = { path: { equals: normalizedPath } }
const cacheKey = generateDocumentCacheKey(collection, path)
const shouldCache = draft ? false : !!cache
return conditionalCache(
async () => {
return await payload
.find({
collection,
draft,
depth,
limit: 1,
overrideAccess: false,
user,
where,
})
.catch((error) => {
console.error('Error fetching document:', error)
return null
})
.then((result) => {
if (!result || !result.docs || result.docs.length === 0) return null
return result.docs.at(0) as Config['collections'][K] | null
})
},
cacheKey,
shouldCache,
revalidate,
)
}

View File

@ -0,0 +1,9 @@
const normalizePath = (path?: string | string[], keepTrailingSlash: boolean = false): string => {
if (!path) return '/'
if (Array.isArray(path)) path = path.join('/')
path = `/${path}/`.replace(/\/+/g, '/')
path = path !== '/' && !keepTrailingSlash ? path.replace(/\/$/, '') : path
return path
}
export default normalizePath

View File

@ -5,3 +5,170 @@
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User;
posts: Post;
authors: Author;
media: Media;
pages: Page;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
globals: {};
locale: null;
user: User & {
collection: 'users';
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
roles: ('admin' | 'editor' | 'user')[];
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title: string;
summary?: string | null;
publishedDate?: string | null;
thumbnail: string | Media;
author?: (string | null) | Author;
status: 'draft' | 'published' | 'archived';
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "authors".
*/
export interface Author {
id: string;
avatar: string | Media;
name: string;
bio?: string | null;
user?: string | User | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: string;
title?: string | null;
blocks?: RichTextBlock[] | null;
slug?: string | null;
path?: string | null;
breadcrumbs?:
| {
doc?: (string | null) | Page;
url?: string | null;
label?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "RichTextBlock".
*/
export interface RichTextBlock {
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'RichText';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}