This commit is contained in:
parent
f9ee909e01
commit
60f7a4e4f2
@ -23,8 +23,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@payloadcms/db-mongodb": "3.0.0-beta.40",
|
"@payloadcms/db-mongodb": "3.0.0-beta.40",
|
||||||
"@payloadcms/next": "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/richtext-lexical": "3.0.0-beta.40",
|
||||||
"@payloadcms/ui": "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",
|
"cross-env": "^7.0.3",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"next": "15.0.0-rc.0",
|
"next": "15.0.0-rc.0",
|
||||||
@ -32,7 +34,7 @@
|
|||||||
"react": "19.0.0-rc-f994737d14-20240522",
|
"react": "19.0.0-rc-f994737d14-20240522",
|
||||||
"react-dom": "19.0.0-rc-f994737d14-20240522",
|
"react-dom": "19.0.0-rc-f994737d14-20240522",
|
||||||
"sharp": "0.32.6",
|
"sharp": "0.32.6",
|
||||||
"babel-plugin-react-compiler": "^0.0.0-experimental-592953e-20240517"
|
"tailwind-merge": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
|
@ -27,6 +27,7 @@ import Media from '@payload/collections/Media'
|
|||||||
import Authors from '@payload/collections/Authors'
|
import Authors from '@payload/collections/Authors'
|
||||||
import Posts from '@payload/collections/Posts'
|
import Posts from '@payload/collections/Posts'
|
||||||
import Users from '@payload/collections/Users'
|
import Users from '@payload/collections/Users'
|
||||||
|
import Pages from '@payload/collections/Pages'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@ -40,28 +41,7 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
collections: [
|
collections: [Users, Posts, Authors, Media, Pages],
|
||||||
Users,
|
|
||||||
Posts,
|
|
||||||
Authors,
|
|
||||||
Media,
|
|
||||||
{
|
|
||||||
slug: 'pages',
|
|
||||||
admin: {
|
|
||||||
useAsTitle: 'title',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'title',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'content',
|
|
||||||
type: 'richText',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
secret: process.env.PAYLOAD_SECRET || '',
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, 'types/payload-types.ts'),
|
outputFile: path.resolve(dirname, 'types/payload-types.ts'),
|
||||||
|
0
src/app/(app)/globals.css
Normal file
0
src/app/(app)/globals.css
Normal 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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +1,23 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import './globals.scss'
|
import './globals.css'
|
||||||
import { Inter } from 'next/font/google'
|
/* import { Inter } from 'next/font/google'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
/* Our app sits here to not cause any conflicts with payload's root layout */
|
/* Our app sits here to not cause any conflicts with payload's root layout */
|
||||||
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
{
|
||||||
|
/* <html className={inter.className}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html className={inter.className}>
|
<html>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
@ -2,49 +2,19 @@ import { Badge } from '@/components/Badge'
|
|||||||
import { Background } from '@/components/Background'
|
import { Background } from '@/components/Background'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import LexicalContent from '@/components/LexicalContent'
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
|
/* const url = 'http://localhost:3000/api/pages/66781d2fb752297de17a3368?depth=1&draft=false'
|
||||||
|
|
||||||
|
const content = fetch(url)
|
||||||
|
console.log(content) */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main>
|
<h1>Lexical content hereinafter</h1>
|
||||||
<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 })
|
|
||||||
|
|
||||||
const data = await payload.find({
|
{/* <LexicalContent lazyLoadImages={false} childrenNodes={content?.root?.children} locale={''} /> */}
|
||||||
collection: 'posts',
|
|
||||||
})
|
|
||||||
|
|
||||||
return <Posts data={data} />
|
|
||||||
`}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Background />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
35
src/app/(app)/pages/[[...path]]/page.tsx
Normal file
35
src/app/(app)/pages/[[...path]]/page.tsx
Normal 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
|
@ -40,3 +40,15 @@ export const isAdminOrSelf = ({ req: { user } }: any) => {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isAdminOrPublished = ({ req: { user } }: any) => {
|
||||||
|
if (user && user?.role === 'admin') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_status: {
|
||||||
|
equals: 'published',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4
src/app/(payload)/blocks/index.ts
Normal file
4
src/app/(payload)/blocks/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import RichText from '@payload/blocks/rich-text'
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
|
export default [RichText]
|
14
src/app/(payload)/blocks/rich-text.ts
Normal file
14
src/app/(payload)/blocks/rich-text.ts
Normal 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
|
62
src/app/(payload)/collections/Pages.ts
Normal file
62
src/app/(payload)/collections/Pages.ts
Normal 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
|
10
src/app/(payload)/collections/config.ts
Normal file
10
src/app/(payload)/collections/config.ts
Normal 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 */
|
16
src/app/(payload)/fields/blocks.ts
Executable file
16
src/app/(payload)/fields/blocks.ts
Executable 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 || {},
|
||||||
|
)
|
||||||
|
}
|
26
src/app/(payload)/fields/icon.ts
Normal file
26
src/app/(payload)/fields/icon.ts
Normal 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
|
2
src/app/(payload)/fields/index.ts
Normal file
2
src/app/(payload)/fields/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as pathField } from "./path";
|
||||||
|
export { default as slugField } from "./slug";
|
122
src/app/(payload)/fields/path.ts
Normal file
122
src/app/(payload)/fields/path.ts
Normal 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
|
49
src/app/(payload)/fields/slug.ts
Normal file
49
src/app/(payload)/fields/slug.ts
Normal 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
|
22
src/components/Blocks/RichText.tsx
Normal file
22
src/components/Blocks/RichText.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
31
src/components/Blocks/index.tsx
Normal file
31
src/components/Blocks/index.tsx
Normal 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
10
src/components/Container.tsx
Executable 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
|
51
src/components/LexicalContent/RichTextNodeFormat.ts
Normal file
51
src/components/LexicalContent/RichTextNodeFormat.ts
Normal 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'
|
213
src/components/LexicalContent/index.tsx
Normal file
213
src/components/LexicalContent/index.tsx
Normal 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
|
18
src/lib/payload/actions.ts
Normal file
18
src/lib/payload/actions.ts
Normal 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
21
src/lib/payload/index.ts
Normal 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
6
src/utils/cn.ts
Executable 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))
|
||||||
|
}
|
7
src/utils/generateBreadcrumbsUrl.ts
Normal file
7
src/utils/generateBreadcrumbsUrl.ts
Normal 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
37
src/utils/generateMeta.ts
Normal 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 },
|
||||||
|
}
|
||||||
|
}
|
13
src/utils/generateRandomString.ts
Normal file
13
src/utils/generateRandomString.ts
Normal 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
80
src/utils/getDocument.ts
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
9
src/utils/normalizePath.ts
Normal file
9
src/utils/normalizePath.ts
Normal 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
|
@ -5,3 +5,170 @@
|
|||||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||||
* and re-run `payload generate:types` to regenerate this file.
|
* 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 {}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user