From 60f7a4e4f287466003593f2cbb4a00b6cc33f384 Mon Sep 17 00:00:00 2001 From: tobias Date: Sun, 23 Jun 2024 22:23:06 +0200 Subject: [PATCH] Add block editing --- package.json | 4 +- payload.config.ts | 24 +- src/app/(app)/globals.css | 0 src/app/(app)/globals.scss | 191 ---------------- src/app/(app)/layout.tsx | 15 +- src/app/(app)/page.tsx | 46 +--- src/app/(app)/pages/[[...path]]/page.tsx | 35 +++ src/app/(payload)/access/isAdmin.ts | 12 + src/app/(payload)/blocks/index.ts | 4 + src/app/(payload)/blocks/rich-text.ts | 14 ++ src/app/(payload)/collections/Pages.ts | 62 +++++ src/app/(payload)/collections/config.ts | 10 + src/app/(payload)/fields/blocks.ts | 16 ++ src/app/(payload)/fields/icon.ts | 26 +++ src/app/(payload)/fields/index.ts | 2 + src/app/(payload)/fields/path.ts | 122 ++++++++++ src/app/(payload)/fields/slug.ts | 49 ++++ src/components/{ => Blocks}/Author.tsx | 0 src/components/Blocks/RichText.tsx | 22 ++ src/components/Blocks/index.tsx | 31 +++ src/components/Container.tsx | 10 + .../LexicalContent/RichTextNodeFormat.ts | 51 +++++ src/components/LexicalContent/index.tsx | 213 ++++++++++++++++++ src/lib/payload/actions.ts | 18 ++ src/lib/payload/index.ts | 21 ++ src/utils/cn.ts | 6 + src/utils/generateBreadcrumbsUrl.ts | 7 + src/utils/generateMeta.ts | 37 +++ src/utils/generateRandomString.ts | 13 ++ src/utils/getDocument.ts | 80 +++++++ src/utils/normalizePath.ts | 9 + types/payload-types.ts | 167 ++++++++++++++ 32 files changed, 1061 insertions(+), 256 deletions(-) create mode 100644 src/app/(app)/globals.css delete mode 100644 src/app/(app)/globals.scss create mode 100644 src/app/(app)/pages/[[...path]]/page.tsx create mode 100644 src/app/(payload)/blocks/index.ts create mode 100644 src/app/(payload)/blocks/rich-text.ts create mode 100644 src/app/(payload)/collections/Pages.ts create mode 100644 src/app/(payload)/collections/config.ts create mode 100755 src/app/(payload)/fields/blocks.ts create mode 100644 src/app/(payload)/fields/icon.ts create mode 100644 src/app/(payload)/fields/index.ts create mode 100644 src/app/(payload)/fields/path.ts create mode 100644 src/app/(payload)/fields/slug.ts rename src/components/{ => Blocks}/Author.tsx (100%) create mode 100644 src/components/Blocks/RichText.tsx create mode 100644 src/components/Blocks/index.tsx create mode 100755 src/components/Container.tsx create mode 100644 src/components/LexicalContent/RichTextNodeFormat.ts create mode 100644 src/components/LexicalContent/index.tsx create mode 100644 src/lib/payload/actions.ts create mode 100644 src/lib/payload/index.ts create mode 100755 src/utils/cn.ts create mode 100644 src/utils/generateBreadcrumbsUrl.ts create mode 100644 src/utils/generateMeta.ts create mode 100644 src/utils/generateRandomString.ts create mode 100644 src/utils/getDocument.ts create mode 100644 src/utils/normalizePath.ts diff --git a/package.json b/package.json index 21efa81..2e15501 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/payload.config.ts b/payload.config.ts index 5c24b46..08ab6d1 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -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'), diff --git a/src/app/(app)/globals.css b/src/app/(app)/globals.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(app)/globals.scss b/src/app/(app)/globals.scss deleted file mode 100644 index dc14469..0000000 --- a/src/app/(app)/globals.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 26f9544..876b4d9 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 }) => { + { + /* + {children} + + */ + } + return ( - + {children} ) diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index f148ed5..c07b66c 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -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 ( <> -
-
- -

Payload 3.0

-

- This BETA is rapidly evolving, you can report any bugs against{' '} - - the repo - {' '} - or in the{' '} - - dedicated channel in Discord - - . Payload is running at /admin. An example of a custom route - running the Local API can be found at /my-route. -

-

You can use the Local API in your server components like this:

-
-
-
-            
-              {`import { getPayloadHMR } from '@payloadcms/next/utilities'
-import configPromise from '@payload-config'
-const payload = await getPayloadHMR({ config: configPromise })
+      

Lexical content hereinafter

-const data = await payload.find({ - collection: 'posts', -}) - -return -`} -
-
-
-
- + {/* */} ) } diff --git a/src/app/(app)/pages/[[...path]]/page.tsx b/src/app/(app)/pages/[[...path]]/page.tsx new file mode 100644 index 0000000..dfb56e9 --- /dev/null +++ b/src/app/(app)/pages/[[...path]]/page.tsx @@ -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 { + 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 +} + +export default Page diff --git a/src/app/(payload)/access/isAdmin.ts b/src/app/(payload)/access/isAdmin.ts index 5751efb..80a979d 100644 --- a/src/app/(payload)/access/isAdmin.ts +++ b/src/app/(payload)/access/isAdmin.ts @@ -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', + }, + } +} diff --git a/src/app/(payload)/blocks/index.ts b/src/app/(payload)/blocks/index.ts new file mode 100644 index 0000000..7be6d16 --- /dev/null +++ b/src/app/(payload)/blocks/index.ts @@ -0,0 +1,4 @@ +import RichText from '@payload/blocks/rich-text' + +// eslint-disable-next-line import/no-anonymous-default-export +export default [RichText] diff --git a/src/app/(payload)/blocks/rich-text.ts b/src/app/(payload)/blocks/rich-text.ts new file mode 100644 index 0000000..6befc08 --- /dev/null +++ b/src/app/(payload)/blocks/rich-text.ts @@ -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 diff --git a/src/app/(payload)/collections/Pages.ts b/src/app/(payload)/collections/Pages.ts new file mode 100644 index 0000000..ade491d --- /dev/null +++ b/src/app/(payload)/collections/Pages.ts @@ -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 diff --git a/src/app/(payload)/collections/config.ts b/src/app/(payload)/collections/config.ts new file mode 100644 index 0000000..28e0b76 --- /dev/null +++ b/src/app/(payload)/collections/config.ts @@ -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 */ diff --git a/src/app/(payload)/fields/blocks.ts b/src/app/(payload)/fields/blocks.ts new file mode 100755 index 0000000..976eb8e --- /dev/null +++ b/src/app/(payload)/fields/blocks.ts @@ -0,0 +1,16 @@ +import type { Field } from 'payload/types' +import blocks from '@payload/blocks' +import deepMerge from 'deepmerge' + +type BlocksField = (overrides?: Partial) => Field + +export const blocksField: BlocksField = (overrides) => { + return deepMerge>( + { + name: 'blocks', + type: 'blocks', + blocks, + }, + overrides || {}, + ) +} diff --git a/src/app/(payload)/fields/icon.ts b/src/app/(payload)/fields/icon.ts new file mode 100644 index 0000000..4987339 --- /dev/null +++ b/src/app/(payload)/fields/icon.ts @@ -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 + +const iconField: IconField = (overrides = {}) => { + return deepMerge>( + { + type: 'select', + name: 'icon', + label: 'Icon', + options: iconOptions + }, + overrides + ) +} + +export default iconField diff --git a/src/app/(payload)/fields/index.ts b/src/app/(payload)/fields/index.ts new file mode 100644 index 0000000..57f3ee6 --- /dev/null +++ b/src/app/(payload)/fields/index.ts @@ -0,0 +1,2 @@ +export { default as pathField } from "./path"; +export { default as slugField } from "./slug"; \ No newline at end of file diff --git a/src/app/(payload)/fields/path.ts b/src/app/(payload)/fields/path.ts new file mode 100644 index 0000000..95fc744 --- /dev/null +++ b/src/app/(payload)/fields/path.ts @@ -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 => { + 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 { + 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 => + deepmerge>( + { + 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 diff --git a/src/app/(payload)/fields/slug.ts b/src/app/(payload)/fields/slug.ts new file mode 100644 index 0000000..c6e4225 --- /dev/null +++ b/src/app/(payload)/fields/slug.ts @@ -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 + +const slugField: Slug = (fieldToUse = 'title', overrides = {}) => { + return deepMerge>( + { + 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 diff --git a/src/components/Author.tsx b/src/components/Blocks/Author.tsx similarity index 100% rename from src/components/Author.tsx rename to src/components/Blocks/Author.tsx diff --git a/src/components/Blocks/RichText.tsx b/src/components/Blocks/RichText.tsx new file mode 100644 index 0000000..d89456e --- /dev/null +++ b/src/components/Blocks/RichText.tsx @@ -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 ( +
+ +
+ +
+
+
+ ) +} diff --git a/src/components/Blocks/index.tsx b/src/components/Blocks/index.tsx new file mode 100644 index 0000000..bb94bd2 --- /dev/null +++ b/src/components/Blocks/index.tsx @@ -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 ? ( + + ) : null + })} + + ) +} + +export default Blocks diff --git a/src/components/Container.tsx b/src/components/Container.tsx new file mode 100755 index 0000000..73d2e35 --- /dev/null +++ b/src/components/Container.tsx @@ -0,0 +1,10 @@ +import cn from '@/utils/cn' +import type { ComponentPropsWithoutRef } from 'react' + +const Container = ({ children, className }: ComponentPropsWithoutRef<'div'>) => { + return ( +
{children}
+ ) +} + +export default Container diff --git a/src/components/LexicalContent/RichTextNodeFormat.ts b/src/components/LexicalContent/RichTextNodeFormat.ts new file mode 100644 index 0000000..63662ab --- /dev/null +++ b/src/components/LexicalContent/RichTextNodeFormat.ts @@ -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 = { + 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' diff --git a/src/components/LexicalContent/index.tsx b/src/components/LexicalContent/index.tsx new file mode 100644 index 0000000..8f00218 --- /dev/null +++ b/src/components/LexicalContent/index.tsx @@ -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 = ({ children, format }) => { + const formatFunctions: { [key: number]: (child: ReactElement | string) => ReactElement } = { + [IS_BOLD]: (child) => {child}, + [IS_ITALIC]: (child) => {child}, + [IS_STRIKETHROUGH]: (child) => {child}, + [IS_UNDERLINE]: (child) => {child}, + [IS_CODE]: (child) => {child}, + [IS_SUBSCRIPT]: (child) => {child}, + [IS_SUPERSCRIPT]: (child) => {child}, + } + + 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 ( + + {children} + + ) +} + +const getNodeClassNames = (node: SerializedLexicalNode) => { + const attributes: Record = {} + 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 ( + + <> + {Object.keys(attributes).length > 0 && {node?.text || ''}} + {(Object.keys(attributes).length === 0 && node?.text) || ''} + + + ) + } + + const serializedChildren = node.children ? ( + + ) : null + switch (node.type) { + case 'linebreak': + return
+ case 'link': + return + 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 ( + + {serializedChildren} + + ) + case 'listitem': + return ( +
  • + {serializedChildren} +
  • + ) + case 'heading': + const HeadingTag = node.tag as keyof JSX.IntrinsicElements + return ( + + {serializedChildren} + + ) + case 'quote': + return ( +
    + {serializedChildren} +
    + ) + case 'upload': + const upload = node?.value + if (!upload) return null + const imageAspectRatio = calculateAspectRatio(upload.width, upload.height) + return ( + {upload?.alt + ) + default: + if ( + Array.isArray(serializedChildren?.props?.childrenNodes) && + serializedChildren?.props?.childrenNodes.length === 0 + ) + return
    + return ( +

    + {serializedChildren} +

    + ) + } + }) + + return <>{renderedChildren.filter((node) => node !== null)} +} + +export default LexicalContent diff --git a/src/lib/payload/actions.ts b/src/lib/payload/actions.ts new file mode 100644 index 0000000..b1bb1d7 --- /dev/null +++ b/src/lib/payload/actions.ts @@ -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}`) + }) + } +} diff --git a/src/lib/payload/index.ts b/src/lib/payload/index.ts new file mode 100644 index 0000000..6042df5 --- /dev/null +++ b/src/lib/payload/index.ts @@ -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 { + 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 { + const headers = getHeaders() + const payload = await getPayload() + return (await payload.auth({ headers })).user +} diff --git a/src/utils/cn.ts b/src/utils/cn.ts new file mode 100755 index 0000000..93d98fb --- /dev/null +++ b/src/utils/cn.ts @@ -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)) +} diff --git a/src/utils/generateBreadcrumbsUrl.ts b/src/utils/generateBreadcrumbsUrl.ts new file mode 100644 index 0000000..f01b8b1 --- /dev/null +++ b/src/utils/generateBreadcrumbsUrl.ts @@ -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) +} diff --git a/src/utils/generateMeta.ts b/src/utils/generateMeta.ts new file mode 100644 index 0000000..91f2644 --- /dev/null +++ b/src/utils/generateMeta.ts @@ -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 => { + 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 }, + } +} diff --git a/src/utils/generateRandomString.ts b/src/utils/generateRandomString.ts new file mode 100644 index 0000000..94b8f79 --- /dev/null +++ b/src/utils/generateRandomString.ts @@ -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 +} diff --git a/src/utils/getDocument.ts b/src/utils/getDocument.ts new file mode 100644 index 0000000..736a2b8 --- /dev/null +++ b/src/utils/getDocument.ts @@ -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 ( + fetchFunction: () => Promise, + cacheKey: string, + cache: boolean | undefined, + revalidate: false | number | undefined, +): Promise => { + if (!cache) { + return fetchFunction() + } + return unstable_cache(fetchFunction, [cacheKey], { revalidate, tags: [cacheKey] })() +} + +interface GetDocumentParams { + collection: K + path?: Path + depth?: number + cache?: CacheOption + revalidate?: false | number | undefined +} + +export const getDocument = async ({ + collection, + path, + depth = 0, + cache = 'noDraft', + revalidate = false, +}: GetDocumentParams): Promise => { + 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, + ) +} diff --git a/src/utils/normalizePath.ts b/src/utils/normalizePath.ts new file mode 100644 index 0000000..59dd10b --- /dev/null +++ b/src/utils/normalizePath.ts @@ -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 diff --git a/types/payload-types.ts b/types/payload-types.ts index 113572b..056796a 100644 --- a/types/payload-types.ts +++ b/types/payload-types.ts @@ -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 {} +} \ No newline at end of file