chore(examples): localization example (#10134)

This commit is contained in:
Germán Jabloñski
2024-12-22 16:21:56 -03:00
committed by GitHub
parent 154ad9d132
commit 3bbc5bf949
192 changed files with 23242 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# Database connection string
DATABASE_URI=mongodb://127.0.0.1/payload-template-website
# Used to encrypt JWT tokens
PAYLOAD_SECRET=YOUR_SECRET_HERE
# Used to format links and URLs
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Allow robots to index the site (optional)
NEXT_PUBLIC_IS_LIVE=
# Used to preview drafts
PAYLOAD_PUBLIC_DRAFT_SECRET=demo-draft-secret
NEXT_PRIVATE_DRAFT_SECRET=demo-draft-secret
# Used to revalidate static pages
REVALIDATION_KEY=demo-revalation-key
NEXT_PRIVATE_REVALIDATION_KEY=demo-revalation-key

View File

@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
playwright.config.ts
jest.config.js

View File

@@ -0,0 +1,8 @@
module.exports = {
extends: 'next',
root: true,
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}

10
examples/localization/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
build
dist / media
node_modules
.DS_Store
.env
.next
.vercel
# Payload default media upload directory
public/media/

View File

@@ -0,0 +1,14 @@
**/payload-types.ts
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

View File

@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"command": "yarn dev",
"name": "Debug Website",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@@ -0,0 +1,24 @@
FROM node:18.8-alpine as base
FROM base as builder
WORKDIR /home/node/app
COPY package*.json ./
COPY . .
RUN yarn install
RUN yarn build
FROM base as runtime
ENV NODE_ENV=production
WORKDIR /home/node/app
COPY package*.json ./
COPY yarn.lock ./
RUN yarn install --production
EXPOSE 3000
CMD ["node", "dist/server.js"]

View File

@@ -0,0 +1,32 @@
# Payload Localization Example (i18n)
This example is built based on an old version of the website template.
The objective is to show how to implement localization in a website. There is no guarantee that it will be kept up to date with the website template or the latest Payload enhancements.
To facilitate the localization process, this example uses the next-intl library.
## Setup
1. `cp .env.example .env` (copy the .env.example file to .env)
2. `pnpm install` (`pnpm i --ignore-workspaces` if you are running from the monorepo)
3. `pnpm run dev`
4. Seed your database in the admin panel (see below)
## Seed
To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel.
The seed script will also create a demo user for demonstration purposes only:
- Demo Author
- Email: `demo-author@payloadcms.com`
- Password: `password`
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
## Important!
The seed script only creates translations in English and Spanish, so you will not see the website translated to other languages even if you see them in the dropdown menu.
You can translate documents to other languages from the admin panel.

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/(frontend)/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utilities"
}
}

View File

@@ -0,0 +1,31 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "yarn install && yarn dev"
depends_on:
- mongo
env_file:
- .env
mongo:
image: mongo:latest
ports:
- '27017:27017'
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
volumes:
data:
node_modules:

5
examples/localization/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,28 @@
import { withPayload } from '@payloadcms/next/withPayload'
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
import redirects from './redirects.js'
const NEXT_PUBLIC_SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
const url = new URL(item)
return {
hostname: url.hostname,
protocol: url.protocol.replace(':', ''),
}
}),
],
},
reactStrictMode: true,
redirects,
}
export default withNextIntl(withPayload(nextConfig))

View File

@@ -0,0 +1,74 @@
{
"name": "payload-localization-example",
"version": "1.0.0",
"description": "An example of how to use Payload's localization features",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-mongodb": "latest",
"@payloadcms/live-preview-react": "latest",
"@payloadcms/next": "latest",
"@payloadcms/payload-cloud": "latest",
"@payloadcms/plugin-form-builder": "latest",
"@payloadcms/plugin-nested-docs": "latest",
"@payloadcms/plugin-redirects": "latest",
"@payloadcms/plugin-search": "latest",
"@payloadcms/plugin-seo": "latest",
"@payloadcms/richtext-lexical": "latest",
"@payloadcms/ui": "latest",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"geist": "^1.3.0",
"graphql": "^16.8.2",
"jsonwebtoken": "9.0.2",
"lucide-react": "^0.378.0",
"next": "^15.1.0",
"next-intl": "^3.23.2",
"payload": "latest",
"payload-admin-bar": "^1.0.6",
"prism-react-renderer": "^2.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "7.45.4",
"sharp": "0.32.6",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/typography": "^0.5.13",
"@types/escape-html": "^1.0.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"autoprefixer": "^10.4.19",
"copyfiles": "^2.4.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.0",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.3",
"typescript": "5.7.2"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
}
}

10271
examples/localization/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
autoprefixer: {},
tailwindcss: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,15 @@
<svg width="260" height="260" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
path {
fill: #0F0F0F;
}
@media (prefers-color-scheme: dark) {
path {
fill: white;
}
}
</style>
<path d="M120.59 8.5824L231.788 75.6142V202.829L148.039 251.418V124.203L36.7866 57.2249L120.59 8.5824Z" />
<path d="M112.123 244.353V145.073L28.2114 193.769L112.123 244.353Z" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,19 @@
const redirects = async () => {
return [
{
// internet explorer
destination: '/ie-incompatible.html',
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
},
]
}
export default redirects

View File

@@ -0,0 +1,3 @@
import type { Access } from 'payload'
export const anyone: Access = () => true

View File

@@ -0,0 +1,9 @@
import type { AccessArgs } from 'payload'
import type { User } from '@/payload-types'
type isAuthenticated = (args: AccessArgs<User>) => boolean
export const authenticated: isAuthenticated = ({ req: { user } }) => {
return Boolean(user)
}

View File

@@ -0,0 +1,13 @@
import type { Access } from 'payload'
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) {
return true
}
return {
_status: {
equals: 'published',
},
}
}

View File

@@ -0,0 +1,15 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
const PageClient: React.FC = () => {
/* Force the header to be dark mode while we have an image behind it */
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('light')
}, [setHeaderTheme])
return <React.Fragment />
}
export default PageClient

View File

@@ -0,0 +1,108 @@
import type { Metadata } from 'next'
import { PayloadRedirects } from '@/components/PayloadRedirects'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import React, { cache } from 'react'
import { homeStatic } from '@/endpoints/seed/home-static'
import type { Page as PageType } from '@/payload-types'
import { RenderBlocks } from '@/blocks/RenderBlocks'
import { RenderHero } from '@/heros/RenderHero'
import { generateMeta } from '@/utilities/generateMeta'
import PageClient from './page.client'
import { TypedLocale } from 'payload'
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const pages = await payload.find({
collection: 'pages',
draft: false,
limit: 1000,
overrideAccess: false,
})
const params = pages.docs
?.filter((doc) => {
return doc.slug !== 'home'
})
.map(({ slug }) => {
return { slug }
})
return params
}
type Args = {
params: Promise<{
slug?: string
locale: TypedLocale
}>
}
export default async function Page({ params: paramsPromise }: Args) {
const { slug = 'home', locale = 'en' } = await paramsPromise
const url = '/' + slug
let page: PageType | null
page = await queryPage({
slug,
locale,
})
// Remove this code once your website is seeded
if (!page && slug === 'home') {
page = homeStatic
}
if (!page) {
return <PayloadRedirects url={url} />
}
const { hero, layout } = page
return (
<article className="pt-16 pb-24">
<PageClient />
{/* Allows redirects for valid pages too */}
<PayloadRedirects disableNotFound url={url} />
<RenderHero {...hero} />
<RenderBlocks blocks={layout} locale={locale} />
</article>
)
}
export async function generateMetadata({ params: paramsPromise }): Promise<Metadata> {
const { slug = 'home', locale = 'en' } = await paramsPromise
const page = await queryPage({
slug,
locale,
})
return generateMeta({ doc: page })
}
const queryPage = cache(async ({ slug, locale }: { slug: string; locale: TypedLocale }) => {
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'pages',
draft,
limit: 1,
locale,
overrideAccess: draft,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
})

View File

@@ -0,0 +1,103 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: auto;
font-weight: auto;
}
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 240 5% 96%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 240 6% 90%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.2rem;
--success: 196 52% 74%;
--warning: 34 89% 85%;
--error: 10 100% 86%;
}
[data-theme='dark'] {
--background: 0 0% 0%;
--foreground: 210 40% 98%;
--card: 240 6% 10%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 240 4% 16%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--success: 196 100% 14%;
--warning: 34 51% 25%;
--error: 10 39% 43%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
html {
opacity: 0;
}
html[data-theme='dark'],
html[data-theme='light'] {
opacity: initial;
}

View File

@@ -0,0 +1,84 @@
import type { Metadata } from 'next'
import { cn } from 'src/utilities/cn'
import { GeistMono } from 'geist/font/mono'
import { GeistSans } from 'geist/font/sans'
import React from 'react'
import { AdminBar } from '@/components/AdminBar'
import { Footer } from '@/globals/Footer/Component'
import { Header } from '@/globals/Header/Component'
import { LivePreviewListener } from '@/components/LivePreviewListener'
import { Providers } from '@/providers'
import { InitTheme } from '@/providers/Theme/InitTheme'
import { mergeOpenGraph } from '@/utilities/mergeOpenGraph'
import { draftMode } from 'next/headers'
import { TypedLocale } from 'payload'
import './globals.css'
import { getMessages, setRequestLocale } from 'next-intl/server'
import { NextIntlClientProvider } from 'next-intl'
import { routing } from '@/i18n/routing'
import { notFound } from 'next/navigation'
type Args = {
children: React.ReactNode
params: Promise<{
locale: TypedLocale
}>
}
export default async function RootLayout({ children, params }: Args) {
const { locale } = await params
if (!routing.locales.includes(locale as any)) {
notFound()
}
setRequestLocale(locale)
const { isEnabled } = await draftMode()
const messages = await getMessages()
return (
<html
className={cn(GeistSans.variable, GeistMono.variable)}
lang={locale}
suppressHydrationWarning
>
<head>
<InitTheme />
<link href="/favicon.ico" rel="icon" sizes="32x32" />
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
</head>
<body>
<Providers>
<NextIntlClientProvider messages={messages}>
<AdminBar
adminBarProps={{
preview: isEnabled,
}}
/>
<LivePreviewListener />
<Header locale={locale} />
{children}
<Footer locale={locale} />
</NextIntlClientProvider>
</Providers>
</body>
</html>
)
}
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SERVER_URL || 'https://payloadcms.com'),
openGraph: mergeOpenGraph(),
twitter: {
card: 'summary_large_image',
creator: '@payloadcms',
},
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}

View File

@@ -0,0 +1,21 @@
import Link from 'next/link'
import React from 'react'
import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
export default function NotFound() {
const t = useTranslations()
return (
<div className="container py-28">
<div className="prose max-w-none">
<h1 style={{ marginBottom: 0 }}>404</h1>
<p className="mb-4">{t('page-not-found')}</p>
</div>
<Button asChild variant="default">
<Link href="/">{t('go-home')}</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { Metadata } from 'next'
// import PageTemplate from './[slug]/page'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import React, { cache } from 'react'
import { generateMeta } from '@/utilities/generateMeta'
import { TypedLocale } from 'payload'
import { PayloadRedirects } from '@/components/PayloadRedirects'
import { homeStatic } from '@/endpoints/seed/home-static'
import type { Page as PageType } from '@/payload-types'
import { RenderBlocks } from '@/blocks/RenderBlocks'
import { RenderHero } from '@/heros/RenderHero'
import PageClient from './[slug]/page.client'
type Args = {
params: Promise<{
slug?: string
locale: TypedLocale
}>
}
export default async function Page({ params: paramsPromise }: Args) {
const { slug = 'home', locale = 'en' } = await paramsPromise
const url = '/' + slug
let page: PageType | null
page = await queryPage({
slug,
locale,
})
// Remove this code once your website is seeded
if (!page && slug === 'home') {
page = homeStatic
}
if (!page) {
return <PayloadRedirects url={url} />
}
const { hero, layout } = page
return (
<article className="pt-16 pb-24">
<PageClient />
<PayloadRedirects disableNotFound url={url} />
<RenderHero {...hero} />
<RenderBlocks blocks={layout} locale={locale} />
</article>
)
}
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { locale = 'en', slug = 'home' } = await params
const page = await queryPage({
locale,
slug,
})
return generateMeta({ doc: page })
}
const queryPage = cache(async ({ locale, slug }: { locale: TypedLocale; slug: string }) => {
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'pages',
draft,
limit: 1,
overrideAccess: draft,
locale: locale,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
})

View File

@@ -0,0 +1,15 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
const PageClient: React.FC = () => {
/* Force the header to be dark mode while we have an image behind it */
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('dark')
}, [setHeaderTheme])
return <React.Fragment />
}
export default PageClient

View File

@@ -0,0 +1,103 @@
import type { Metadata } from 'next'
import { RelatedPosts } from '@/blocks/RelatedPosts/Component'
import { PayloadRedirects } from '@/components/PayloadRedirects'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import React, { cache } from 'react'
import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { PostHero } from '@/heros/PostHero'
import { generateMeta } from '@/utilities/generateMeta'
import PageClient from './page.client'
import { TypedLocale } from 'payload'
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
draft: false,
limit: 1000,
overrideAccess: false,
})
const params = posts.docs.map(({ slug }) => {
return { slug }
})
return params
}
type Args = {
params: Promise<{
slug?: string
locale?: TypedLocale
}>
}
export default async function Post({ params: paramsPromise }: Args) {
const { slug = '', locale = 'en' } = await paramsPromise
const url = '/posts/' + slug
const post = await queryPost({ slug, locale })
if (!post) return <PayloadRedirects url={url} />
return (
<article className="pt-16 pb-16">
<PageClient />
{/* Allows redirects for valid pages too */}
<PayloadRedirects disableNotFound url={url} />
<PostHero post={post} />
<div className="flex flex-col items-center gap-4 pt-8">
<div className="container lg:mx-0 lg:grid lg:grid-cols-[1fr_48rem_1fr] grid-rows-[1fr]">
<RichText
className="lg:grid lg:grid-cols-subgrid col-start-1 col-span-3 grid-rows-[1fr]"
content={post.content}
enableGutter={false}
/>
</div>
{post.relatedPosts && post.relatedPosts.length > 0 && (
<RelatedPosts
className="mt-12"
docs={post.relatedPosts.filter((post) => typeof post === 'object')}
/>
)}
</div>
</article>
)
}
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
const { slug = '', locale = 'en' } = await paramsPromise
const post = await queryPost({ slug, locale })
return generateMeta({ doc: post })
}
const queryPost = cache(async ({ slug, locale }: { slug: string; locale: TypedLocale }) => {
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'posts',
draft,
limit: 1,
overrideAccess: draft,
locale,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
})

View File

@@ -0,0 +1,15 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
const PageClient: React.FC = () => {
/* Force the header to be dark mode while we have an image behind it */
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('light')
}, [setHeaderTheme])
return <React.Fragment />
}
export default PageClient

View File

@@ -0,0 +1,67 @@
import type { Metadata } from 'next/types'
import { CollectionArchive } from '@/components/CollectionArchive'
import { PageRange } from '@/components/PageRange'
import { Pagination } from '@/components/Pagination'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React from 'react'
import PageClient from './page.client'
import { TypedLocale } from 'payload'
import { getTranslations, setRequestLocale } from 'next-intl/server'
export const revalidate = 600
type Args = {
params: Promise<{
locale: TypedLocale
}>
}
export default async function Page({ params }: Args) {
const { locale = 'en' } = await params
const t = await getTranslations()
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
locale,
depth: 1,
limit: 12,
overrideAccess: false,
})
return (
<div className="pt-24 pb-24">
<PageClient />
<div className="container mb-16">
<div className="prose dark:prose-invert max-w-none">
<h1>{t('posts')}</h1>
</div>
</div>
<div className="container mb-8">
<PageRange
collection="posts"
currentPage={posts.page}
limit={12}
totalDocs={posts.totalDocs}
/>
</div>
<CollectionArchive posts={posts.docs} />
<div className="container">
{posts.totalPages > 1 && posts.page && (
<Pagination page={posts.page} totalPages={posts.totalPages} />
)}
</div>
</div>
)
}
export function generateMetadata(): Metadata {
return {
title: `Payload Website Template Posts`,
}
}

View File

@@ -0,0 +1,15 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
const PageClient: React.FC = () => {
/* Force the header to be dark mode while we have an image behind it */
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('light')
}, [setHeaderTheme])
return <React.Fragment />
}
export default PageClient

View File

@@ -0,0 +1,94 @@
import type { Metadata } from 'next/types'
import { CollectionArchive } from '@/components/CollectionArchive'
import { PageRange } from '@/components/PageRange'
import { Pagination } from '@/components/Pagination'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React from 'react'
import PageClient from './page.client'
import { notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { TypedLocale } from 'payload'
export const revalidate = 600
type Args = {
params: Promise<{
pageNumber: string
locale: TypedLocale
}>
}
export default async function Page({ params: paramsPromise }: Args) {
const { pageNumber, locale } = await paramsPromise
const payload = await getPayload({ config: configPromise })
const t = await getTranslations()
const sanitizedPageNumber = Number(pageNumber)
if (!Number.isInteger(sanitizedPageNumber)) notFound()
const posts = await payload.find({
collection: 'posts',
depth: 1,
limit: 12,
locale,
page: sanitizedPageNumber,
overrideAccess: false,
})
return (
<div className="pt-24 pb-24">
<PageClient />
<div className="container mb-16">
<div className="prose dark:prose-invert max-w-none">
<h1>{t('posts')}</h1>
</div>
</div>
<div className="container mb-8">
<PageRange
collection="posts"
currentPage={posts.page}
limit={12}
totalDocs={posts.totalDocs}
/>
</div>
<CollectionArchive posts={posts.docs} />
<div className="container">
{posts.totalPages > 1 && posts.page && (
<Pagination page={posts.page} totalPages={posts.totalPages} />
)}
</div>
</div>
)
}
export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
const { pageNumber } = await paramsPromise
return {
title: `Payload Website Template Posts Page ${pageNumber || ''}`,
}
}
export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
depth: 0,
limit: 10,
draft: false,
overrideAccess: false,
})
const pages: { pageNumber: string }[] = []
for (let i = 1; i <= posts.totalPages; i++) {
pages.push({ pageNumber: String(i) })
}
return pages
}

View File

@@ -0,0 +1,15 @@
'use client'
import { useHeaderTheme } from '@/providers/HeaderTheme'
import React, { useEffect } from 'react'
const PageClient: React.FC = () => {
/* Force the header to be dark mode while we have an image behind it */
const { setHeaderTheme } = useHeaderTheme()
useEffect(() => {
setHeaderTheme('light')
}, [setHeaderTheme])
return <React.Fragment />
}
export default PageClient

View File

@@ -0,0 +1,88 @@
import type { Metadata } from 'next/types'
import { CollectionArchive } from '@/components/CollectionArchive'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React from 'react'
import { Post } from '@/payload-types'
import { Search } from '@/search/Component'
import PageClient from './page.client'
import { getTranslations } from 'next-intl/server'
import { TypedLocale } from 'payload'
type Args = {
searchParams: Promise<{
q: string
}>
params: Promise<{
locale: TypedLocale
}>
}
export default async function Page({
searchParams: searchParamsPromise,
params: paramsPromise,
}: Args) {
const { q: query } = await searchParamsPromise
const { locale } = await paramsPromise
const payload = await getPayload({ config: configPromise })
const t = await getTranslations()
const posts = await payload.find({
collection: 'search',
depth: 1,
limit: 12,
locale,
...(query
? {
where: {
or: [
{
title: {
like: query,
},
},
{
'meta.description': {
like: query,
},
},
{
'meta.title': {
like: query,
},
},
{
slug: {
like: query,
},
},
],
},
}
: {}),
})
return (
<div className="pt-24 pb-24">
<PageClient />
<div className="container mb-16">
<div className="prose dark:prose-invert max-w-none">
<h1 className="sr-only">{t('search')}</h1>
<Search />
</div>
</div>
{posts.totalDocs > 0 ? (
<CollectionArchive posts={posts.docs as unknown as Post[]} />
) : (
<div className="container">No results found.</div>
)}
</div>
)
}
export function generateMetadata(): Metadata {
return {
title: `Payload Website Template Search`,
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound

View File

@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@@ -0,0 +1,61 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
import { default as default_035a063f0e4325a280e3cc815d2ec5d7 } from '@/components/AfterDashboard'
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
export const importMap = {
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/plugin-seo/client#OverviewComponent':
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@payloadcms/plugin-seo/client#MetaTitleComponent':
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@payloadcms/plugin-seo/client#MetaImageComponent':
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@payloadcms/plugin-seo/client#PreviewComponent':
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
'@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
'@/components/AfterDashboard#default': default_035a063f0e4325a280e3cc815d2ec5d7,
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
}

View File

@@ -0,0 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)

View File

@@ -0,0 +1,32 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config'
import '@payloadcms/next/css'
import React from 'react'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import './custom.scss'
import { importMap } from './admin/importMap'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout importMap={importMap} config={configPromise} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -0,0 +1,76 @@
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React from 'react'
import RichText from '@/components/RichText'
import { CollectionArchive } from '@/components/CollectionArchive'
import { TypedLocale } from 'payload'
export const ArchiveBlock: React.FC<
ArchiveBlockProps & {
id?: string
locale: TypedLocale
}
> = async (props) => {
const {
id,
categories,
introContent,
limit: limitFromProps,
populateBy,
selectedDocs,
locale,
} = props
const limit = limitFromProps || 3
let posts: Post[] = []
if (populateBy === 'collection') {
const payload = await getPayload({ config: configPromise })
const flattenedCategories = categories?.map((category) => {
if (typeof category === 'object') return category.id
else return category
})
const fetchedPosts = await payload.find({
collection: 'posts',
depth: 1,
locale,
limit,
...(flattenedCategories && flattenedCategories.length > 0
? {
where: {
categories: {
in: flattenedCategories,
},
},
}
: {}),
})
posts = fetchedPosts.docs
} else {
if (selectedDocs?.length) {
const filteredSelectedPosts = selectedDocs.map((post) => {
if (typeof post.value === 'object') return post.value
}) as Post[]
posts = filteredSelectedPosts
}
}
return (
<div className="my-16" id={`block-${id}`}>
{introContent && (
<div className="container mb-16">
<RichText className="ml-0 max-w-[48rem]" content={introContent} enableGutter={false} />
</div>
)}
<CollectionArchive posts={posts} />
</div>
)
}

View File

@@ -0,0 +1,95 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const Archive: Block = {
slug: 'archive',
interfaceName: 'ArchiveBlock',
fields: [
{
name: 'introContent',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: 'Intro Content',
},
{
name: 'populateBy',
type: 'select',
defaultValue: 'collection',
options: [
{
label: 'Collection',
value: 'collection',
},
{
label: 'Individual Selection',
value: 'selection',
},
],
},
{
name: 'relationTo',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
defaultValue: 'posts',
label: 'Collections To Show',
options: [
{
label: 'Posts',
value: 'posts',
},
],
},
{
name: 'categories',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
hasMany: true,
label: 'Categories To Show',
relationTo: 'categories',
},
{
name: 'limit',
type: 'number',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
step: 1,
},
defaultValue: 10,
label: 'Limit',
},
{
name: 'selectedDocs',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'selection',
},
hasMany: true,
label: 'Selection',
relationTo: ['posts'],
},
],
labels: {
plural: 'Archives',
singular: 'Archive',
},
}

View File

@@ -0,0 +1,26 @@
import type { BannerBlock as BannerBlockProps } from 'src/payload-types'
import { cn } from 'src/utilities/cn'
import React from 'react'
import RichText from '@/components/RichText'
type Props = {
className?: string
} & BannerBlockProps
export const BannerBlock: React.FC<Props> = ({ className, content, style }) => {
return (
<div className={cn('mx-auto my-8 w-full', className)}>
<div
className={cn('border py-3 px-6 flex items-center rounded', {
'border-border bg-card': style === 'info',
'border-error bg-error/30': style === 'error',
'border-success bg-success/30': style === 'success',
'border-warning bg-warning/30': style === 'warning',
})}
>
<RichText content={content} enableGutter={false} enableProse={false} />
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const Banner: Block = {
slug: 'banner',
fields: [
{
name: 'style',
type: 'select',
defaultValue: 'info',
options: [
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Error', value: 'error' },
{ label: 'Success', value: 'success' },
],
required: true,
},
{
name: 'content',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
},
}),
label: false,
required: true,
},
],
interfaceName: 'BannerBlock',
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import type { Page } from '@/payload-types'
import RichText from '@/components/RichText'
import { CMSLink } from '@/components/Link'
type Props = Extract<Page['layout'][0], { blockType: 'cta' }>
export const CallToActionBlock: React.FC<
Props & {
id?: string
}
> = ({ links, richText }) => {
return (
<div className="container">
<div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
<div className="max-w-[48rem] flex items-center">
{richText && <RichText className="mb-0" content={richText} enableGutter={false} />}
</div>
<div className="flex flex-col gap-8">
{(links || []).map(({ link }, i) => {
return <CMSLink key={i} size="lg" {...link} />
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { linkGroup } from '../../fields/linkGroup'
export const CallToAction: Block = {
slug: 'cta',
interfaceName: 'CallToActionBlock',
fields: [
{
name: 'richText',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
linkGroup({
appearances: ['default', 'outline'],
overrides: {
maxRows: 2,
},
}),
],
labels: {
plural: 'Calls to Action',
singular: 'Call to Action',
},
}

View File

@@ -0,0 +1,31 @@
'use client'
import { Highlight, themes } from 'prism-react-renderer'
import React from 'react'
type Props = {
code: string
language?: string
}
export const Code: React.FC<Props> = ({ code, language = '' }) => {
if (!code) return null
return (
<Highlight code={code} language={language} theme={themes.vsDark}>
{({ getLineProps, getTokenProps, tokens }) => (
<pre className="bg-black p-4 border text-xs border-border rounded overflow-x-auto">
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ className: 'table-row', line })}>
<span className="table-cell select-none text-right text-white/25">{i + 1}</span>
<span className="table-cell pl-4">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
))}
</pre>
)}
</Highlight>
)
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
import { Code } from './Component.client'
export type CodeBlockProps = {
code: string
language?: string
blockType: 'code'
}
type Props = CodeBlockProps & {
className?: string
}
export const CodeBlock: React.FC<Props> = ({ className, code, language }) => {
return (
<div className={[className, 'not-prose'].filter(Boolean).join(' ')}>
<Code code={code} language={language} />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { Block } from 'payload'
export const Code: Block = {
slug: 'code',
interfaceName: 'CodeBlock',
fields: [
{
name: 'language',
type: 'select',
defaultValue: 'typescript',
options: [
{
label: 'Typescript',
value: 'typescript',
},
{
label: 'Javascript',
value: 'javascript',
},
{
label: 'CSS',
value: 'css',
},
],
},
{
name: 'code',
type: 'code',
label: false,
required: true,
},
],
}

View File

@@ -0,0 +1,49 @@
import { cn } from 'src/utilities/cn'
import React from 'react'
import RichText from '@/components/RichText'
import type { Page } from '@/payload-types'
import { CMSLink } from '../../components/Link'
type Props = Extract<Page['layout'][0], { blockType: 'content' }>
export const ContentBlock: React.FC<
{
id?: string
} & Props
> = (props) => {
const { columns } = props
const colsSpanClasses = {
full: '12',
half: '6',
oneThird: '4',
twoThirds: '8',
}
return (
<div className="container my-16">
<div className="grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16">
{columns &&
columns.length > 0 &&
columns.map((col, index) => {
const { enableLink, link, richText, size } = col
return (
<div
className={cn(`col-span-4 lg:col-span-${colsSpanClasses[size!]}`, {
'md:col-span-2': size !== 'full',
})}
key={index}
>
{richText && <RichText content={richText} enableGutter={false} />}
{enableLink && <CMSLink {...link} />}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import type { Block, Field } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { link } from '@/fields/link'
const columnFields: Field[] = [
{
name: 'size',
type: 'select',
defaultValue: 'oneThird',
options: [
{
label: 'One Third',
value: 'oneThird',
},
{
label: 'Half',
value: 'half',
},
{
label: 'Two Thirds',
value: 'twoThirds',
},
{
label: 'Full',
value: 'full',
},
],
},
{
name: 'richText',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_, { enableLink }) => Boolean(enableLink),
},
},
}),
]
export const Content: Block = {
slug: 'content',
interfaceName: 'ContentBlock',
fields: [
{
name: 'columns',
type: 'array',
fields: columnFields,
},
],
}

View File

@@ -0,0 +1,44 @@
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { useFormContext } from 'react-hook-form'
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Checkbox: React.FC<
CheckboxField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
getValues: any
register: UseFormRegister<FieldValues>
setValue: any
}
> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => {
const props = register(name, { required: requiredFromProps })
const { setValue } = useFormContext()
return (
<Width width={width}>
<div className="flex items-center gap-2">
<CheckboxUi
defaultChecked={defaultValue}
id={name}
{...props}
onCheckedChange={(checked) => {
setValue(props.name, checked)
}}
/>
<Label htmlFor={name}>{label}</Label>
</div>
{requiredFromProps && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import type { Form as FormType } from '@payloadcms/plugin-form-builder/types'
import { useRouter } from 'next/navigation'
import React, { useCallback, useState } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import { buildInitialFormState } from './buildInitialFormState'
import { fields } from './fields'
import { useTranslations } from 'next-intl'
export type Value = unknown
export interface Property {
[key: string]: Value
}
export interface Data {
[key: string]: Property | Property[]
}
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: {
[k: string]: unknown
}[]
}
export const FormBlock: React.FC<
{
id?: string
} & FormBlockType
> = (props) => {
const {
enableIntro,
form: formFromProps,
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
introContent,
} = props
const formMethods = useForm({
defaultValues: buildInitialFormState(formFromProps.fields),
})
const {
control,
formState: { errors },
handleSubmit,
register,
} = formMethods
const [isLoading, setIsLoading] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
const router = useRouter()
const t = useTranslations()
const onSubmit = useCallback(
(data: Data) => {
let loadingTimerID: ReturnType<typeof setTimeout>
const submitForm = async () => {
setError(undefined)
const dataToSend = Object.entries(data).map(([name, value]) => ({
field: name,
value,
}))
// delay loading indicator by 1s
loadingTimerID = setTimeout(() => {
setIsLoading(true)
}, 1000)
try {
const req = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/form-submissions`, {
body: JSON.stringify({
form: formID,
submissionData: dataToSend,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const res = await req.json()
clearTimeout(loadingTimerID)
if (req.status >= 400) {
setIsLoading(false)
setError({
message: res.errors?.[0]?.message || 'Internal Server Error',
status: res.status,
})
return
}
setIsLoading(false)
setHasSubmitted(true)
if (confirmationType === 'redirect' && redirect) {
const { url } = redirect
const redirectUrl = url
if (redirectUrl) router.push(redirectUrl)
}
} catch (err) {
console.warn(err)
setIsLoading(false)
setError({
message: 'Something went wrong.',
})
}
}
void submitForm()
},
[router, formID, redirect, confirmationType],
)
return (
<div className="container lg:max-w-[48rem] pb-20">
<FormProvider {...formMethods}>
{enableIntro && introContent && !hasSubmitted && (
<RichText className="mb-8" content={introContent} enableGutter={false} />
)}
{!isLoading && hasSubmitted && confirmationType === 'message' && (
<RichText content={confirmationMessage} />
)}
{isLoading && !hasSubmitted && <p>{t('loading')}</p>}
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
{!hasSubmitted && (
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 last:mb-0">
{formFromProps &&
formFromProps.fields &&
formFromProps.fields?.map((field, index) => {
const Field: React.FC<any> = fields?.[field.blockType]
if (Field) {
return (
<div className="mb-6 last:mb-0" key={index}>
<Field
form={formFromProps}
{...field}
{...formMethods}
control={control}
errors={errors}
register={register}
/>
</div>
)
}
return null
})}
</div>
<Button form={formID} type="submit" variant="default">
{submitButtonLabel}
</Button>
</form>
)}
</FormProvider>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
import { countryOptions } from './options'
export const Country: React.FC<
CountryField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label className="" htmlFor={name}>
{label}
</Label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = countryOptions.find((t) => t.value === value)
return (
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{countryOptions.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,982 @@
export const countryOptions = [
{
label: 'Afghanistan',
value: 'AF',
},
{
label: 'Åland Islands',
value: 'AX',
},
{
label: 'Albania',
value: 'AL',
},
{
label: 'Algeria',
value: 'DZ',
},
{
label: 'American Samoa',
value: 'AS',
},
{
label: 'Andorra',
value: 'AD',
},
{
label: 'Angola',
value: 'AO',
},
{
label: 'Anguilla',
value: 'AI',
},
{
label: 'Antarctica',
value: 'AQ',
},
{
label: 'Antigua and Barbuda',
value: 'AG',
},
{
label: 'Argentina',
value: 'AR',
},
{
label: 'Armenia',
value: 'AM',
},
{
label: 'Aruba',
value: 'AW',
},
{
label: 'Australia',
value: 'AU',
},
{
label: 'Austria',
value: 'AT',
},
{
label: 'Azerbaijan',
value: 'AZ',
},
{
label: 'Bahamas',
value: 'BS',
},
{
label: 'Bahrain',
value: 'BH',
},
{
label: 'Bangladesh',
value: 'BD',
},
{
label: 'Barbados',
value: 'BB',
},
{
label: 'Belarus',
value: 'BY',
},
{
label: 'Belgium',
value: 'BE',
},
{
label: 'Belize',
value: 'BZ',
},
{
label: 'Benin',
value: 'BJ',
},
{
label: 'Bermuda',
value: 'BM',
},
{
label: 'Bhutan',
value: 'BT',
},
{
label: 'Bolivia',
value: 'BO',
},
{
label: 'Bosnia and Herzegovina',
value: 'BA',
},
{
label: 'Botswana',
value: 'BW',
},
{
label: 'Bouvet Island',
value: 'BV',
},
{
label: 'Brazil',
value: 'BR',
},
{
label: 'British Indian Ocean Territory',
value: 'IO',
},
{
label: 'Brunei Darussalam',
value: 'BN',
},
{
label: 'Bulgaria',
value: 'BG',
},
{
label: 'Burkina Faso',
value: 'BF',
},
{
label: 'Burundi',
value: 'BI',
},
{
label: 'Cambodia',
value: 'KH',
},
{
label: 'Cameroon',
value: 'CM',
},
{
label: 'Canada',
value: 'CA',
},
{
label: 'Cape Verde',
value: 'CV',
},
{
label: 'Cayman Islands',
value: 'KY',
},
{
label: 'Central African Republic',
value: 'CF',
},
{
label: 'Chad',
value: 'TD',
},
{
label: 'Chile',
value: 'CL',
},
{
label: 'China',
value: 'CN',
},
{
label: 'Christmas Island',
value: 'CX',
},
{
label: 'Cocos (Keeling) Islands',
value: 'CC',
},
{
label: 'Colombia',
value: 'CO',
},
{
label: 'Comoros',
value: 'KM',
},
{
label: 'Congo',
value: 'CG',
},
{
label: 'Congo, The Democratic Republic of the',
value: 'CD',
},
{
label: 'Cook Islands',
value: 'CK',
},
{
label: 'Costa Rica',
value: 'CR',
},
{
label: "Cote D'Ivoire",
value: 'CI',
},
{
label: 'Croatia',
value: 'HR',
},
{
label: 'Cuba',
value: 'CU',
},
{
label: 'Cyprus',
value: 'CY',
},
{
label: 'Czech Republic',
value: 'CZ',
},
{
label: 'Denmark',
value: 'DK',
},
{
label: 'Djibouti',
value: 'DJ',
},
{
label: 'Dominica',
value: 'DM',
},
{
label: 'Dominican Republic',
value: 'DO',
},
{
label: 'Ecuador',
value: 'EC',
},
{
label: 'Egypt',
value: 'EG',
},
{
label: 'El Salvador',
value: 'SV',
},
{
label: 'Equatorial Guinea',
value: 'GQ',
},
{
label: 'Eritrea',
value: 'ER',
},
{
label: 'Estonia',
value: 'EE',
},
{
label: 'Ethiopia',
value: 'ET',
},
{
label: 'Falkland Islands (Malvinas)',
value: 'FK',
},
{
label: 'Faroe Islands',
value: 'FO',
},
{
label: 'Fiji',
value: 'FJ',
},
{
label: 'Finland',
value: 'FI',
},
{
label: 'France',
value: 'FR',
},
{
label: 'French Guiana',
value: 'GF',
},
{
label: 'French Polynesia',
value: 'PF',
},
{
label: 'French Southern Territories',
value: 'TF',
},
{
label: 'Gabon',
value: 'GA',
},
{
label: 'Gambia',
value: 'GM',
},
{
label: 'Georgia',
value: 'GE',
},
{
label: 'Germany',
value: 'DE',
},
{
label: 'Ghana',
value: 'GH',
},
{
label: 'Gibraltar',
value: 'GI',
},
{
label: 'Greece',
value: 'GR',
},
{
label: 'Greenland',
value: 'GL',
},
{
label: 'Grenada',
value: 'GD',
},
{
label: 'Guadeloupe',
value: 'GP',
},
{
label: 'Guam',
value: 'GU',
},
{
label: 'Guatemala',
value: 'GT',
},
{
label: 'Guernsey',
value: 'GG',
},
{
label: 'Guinea',
value: 'GN',
},
{
label: 'Guinea-Bissau',
value: 'GW',
},
{
label: 'Guyana',
value: 'GY',
},
{
label: 'Haiti',
value: 'HT',
},
{
label: 'Heard Island and Mcdonald Islands',
value: 'HM',
},
{
label: 'Holy See (Vatican City State)',
value: 'VA',
},
{
label: 'Honduras',
value: 'HN',
},
{
label: 'Hong Kong',
value: 'HK',
},
{
label: 'Hungary',
value: 'HU',
},
{
label: 'Iceland',
value: 'IS',
},
{
label: 'India',
value: 'IN',
},
{
label: 'Indonesia',
value: 'ID',
},
{
label: 'Iran, Islamic Republic Of',
value: 'IR',
},
{
label: 'Iraq',
value: 'IQ',
},
{
label: 'Ireland',
value: 'IE',
},
{
label: 'Isle of Man',
value: 'IM',
},
{
label: 'Israel',
value: 'IL',
},
{
label: 'Italy',
value: 'IT',
},
{
label: 'Jamaica',
value: 'JM',
},
{
label: 'Japan',
value: 'JP',
},
{
label: 'Jersey',
value: 'JE',
},
{
label: 'Jordan',
value: 'JO',
},
{
label: 'Kazakhstan',
value: 'KZ',
},
{
label: 'Kenya',
value: 'KE',
},
{
label: 'Kiribati',
value: 'KI',
},
{
label: "Democratic People's Republic of Korea",
value: 'KP',
},
{
label: 'Korea, Republic of',
value: 'KR',
},
{
label: 'Kosovo',
value: 'XK',
},
{
label: 'Kuwait',
value: 'KW',
},
{
label: 'Kyrgyzstan',
value: 'KG',
},
{
label: "Lao People's Democratic Republic",
value: 'LA',
},
{
label: 'Latvia',
value: 'LV',
},
{
label: 'Lebanon',
value: 'LB',
},
{
label: 'Lesotho',
value: 'LS',
},
{
label: 'Liberia',
value: 'LR',
},
{
label: 'Libyan Arab Jamahiriya',
value: 'LY',
},
{
label: 'Liechtenstein',
value: 'LI',
},
{
label: 'Lithuania',
value: 'LT',
},
{
label: 'Luxembourg',
value: 'LU',
},
{
label: 'Macao',
value: 'MO',
},
{
label: 'Macedonia, The Former Yugoslav Republic of',
value: 'MK',
},
{
label: 'Madagascar',
value: 'MG',
},
{
label: 'Malawi',
value: 'MW',
},
{
label: 'Malaysia',
value: 'MY',
},
{
label: 'Maldives',
value: 'MV',
},
{
label: 'Mali',
value: 'ML',
},
{
label: 'Malta',
value: 'MT',
},
{
label: 'Marshall Islands',
value: 'MH',
},
{
label: 'Martinique',
value: 'MQ',
},
{
label: 'Mauritania',
value: 'MR',
},
{
label: 'Mauritius',
value: 'MU',
},
{
label: 'Mayotte',
value: 'YT',
},
{
label: 'Mexico',
value: 'MX',
},
{
label: 'Micronesia, Federated States of',
value: 'FM',
},
{
label: 'Moldova, Republic of',
value: 'MD',
},
{
label: 'Monaco',
value: 'MC',
},
{
label: 'Mongolia',
value: 'MN',
},
{
label: 'Montenegro',
value: 'ME',
},
{
label: 'Montserrat',
value: 'MS',
},
{
label: 'Morocco',
value: 'MA',
},
{
label: 'Mozambique',
value: 'MZ',
},
{
label: 'Myanmar',
value: 'MM',
},
{
label: 'Namibia',
value: 'NA',
},
{
label: 'Nauru',
value: 'NR',
},
{
label: 'Nepal',
value: 'NP',
},
{
label: 'Netherlands',
value: 'NL',
},
{
label: 'Netherlands Antilles',
value: 'AN',
},
{
label: 'New Caledonia',
value: 'NC',
},
{
label: 'New Zealand',
value: 'NZ',
},
{
label: 'Nicaragua',
value: 'NI',
},
{
label: 'Niger',
value: 'NE',
},
{
label: 'Nigeria',
value: 'NG',
},
{
label: 'Niue',
value: 'NU',
},
{
label: 'Norfolk Island',
value: 'NF',
},
{
label: 'Northern Mariana Islands',
value: 'MP',
},
{
label: 'Norway',
value: 'NO',
},
{
label: 'Oman',
value: 'OM',
},
{
label: 'Pakistan',
value: 'PK',
},
{
label: 'Palau',
value: 'PW',
},
{
label: 'Palestinian Territory, Occupied',
value: 'PS',
},
{
label: 'Panama',
value: 'PA',
},
{
label: 'Papua New Guinea',
value: 'PG',
},
{
label: 'Paraguay',
value: 'PY',
},
{
label: 'Peru',
value: 'PE',
},
{
label: 'Philippines',
value: 'PH',
},
{
label: 'Pitcairn',
value: 'PN',
},
{
label: 'Poland',
value: 'PL',
},
{
label: 'Portugal',
value: 'PT',
},
{
label: 'Puerto Rico',
value: 'PR',
},
{
label: 'Qatar',
value: 'QA',
},
{
label: 'Reunion',
value: 'RE',
},
{
label: 'Romania',
value: 'RO',
},
{
label: 'Russian Federation',
value: 'RU',
},
{
label: 'Rwanda',
value: 'RW',
},
{
label: 'Saint Helena',
value: 'SH',
},
{
label: 'Saint Kitts and Nevis',
value: 'KN',
},
{
label: 'Saint Lucia',
value: 'LC',
},
{
label: 'Saint Pierre and Miquelon',
value: 'PM',
},
{
label: 'Saint Vincent and the Grenadines',
value: 'VC',
},
{
label: 'Samoa',
value: 'WS',
},
{
label: 'San Marino',
value: 'SM',
},
{
label: 'Sao Tome and Principe',
value: 'ST',
},
{
label: 'Saudi Arabia',
value: 'SA',
},
{
label: 'Senegal',
value: 'SN',
},
{
label: 'Serbia',
value: 'RS',
},
{
label: 'Seychelles',
value: 'SC',
},
{
label: 'Sierra Leone',
value: 'SL',
},
{
label: 'Singapore',
value: 'SG',
},
{
label: 'Slovakia',
value: 'SK',
},
{
label: 'Slovenia',
value: 'SI',
},
{
label: 'Solomon Islands',
value: 'SB',
},
{
label: 'Somalia',
value: 'SO',
},
{
label: 'South Africa',
value: 'ZA',
},
{
label: 'South Georgia and the South Sandwich Islands',
value: 'GS',
},
{
label: 'Spain',
value: 'ES',
},
{
label: 'Sri Lanka',
value: 'LK',
},
{
label: 'Sudan',
value: 'SD',
},
{
label: 'Suriname',
value: 'SR',
},
{
label: 'Svalbard and Jan Mayen',
value: 'SJ',
},
{
label: 'Swaziland',
value: 'SZ',
},
{
label: 'Sweden',
value: 'SE',
},
{
label: 'Switzerland',
value: 'CH',
},
{
label: 'Syrian Arab Republic',
value: 'SY',
},
{
label: 'Taiwan',
value: 'TW',
},
{
label: 'Tajikistan',
value: 'TJ',
},
{
label: 'Tanzania, United Republic of',
value: 'TZ',
},
{
label: 'Thailand',
value: 'TH',
},
{
label: 'Timor-Leste',
value: 'TL',
},
{
label: 'Togo',
value: 'TG',
},
{
label: 'Tokelau',
value: 'TK',
},
{
label: 'Tonga',
value: 'TO',
},
{
label: 'Trinidad and Tobago',
value: 'TT',
},
{
label: 'Tunisia',
value: 'TN',
},
{
label: 'Turkey',
value: 'TR',
},
{
label: 'Turkmenistan',
value: 'TM',
},
{
label: 'Turks and Caicos Islands',
value: 'TC',
},
{
label: 'Tuvalu',
value: 'TV',
},
{
label: 'Uganda',
value: 'UG',
},
{
label: 'Ukraine',
value: 'UA',
},
{
label: 'United Arab Emirates',
value: 'AE',
},
{
label: 'United Kingdom',
value: 'GB',
},
{
label: 'United States',
value: 'US',
},
{
label: 'United States Minor Outlying Islands',
value: 'UM',
},
{
label: 'Uruguay',
value: 'UY',
},
{
label: 'Uzbekistan',
value: 'UZ',
},
{
label: 'Vanuatu',
value: 'VU',
},
{
label: 'Venezuela',
value: 'VE',
},
{
label: 'Viet Nam',
value: 'VN',
},
{
label: 'Virgin Islands, British',
value: 'VG',
},
{
label: 'Virgin Islands, U.S.',
value: 'VI',
},
{
label: 'Wallis and Futuna',
value: 'WF',
},
{
label: 'Western Sahara',
value: 'EH',
},
{
label: 'Yemen',
value: 'YE',
},
{
label: 'Zambia',
value: 'ZM',
},
{
label: 'Zimbabwe',
value: 'ZW',
},
]

View File

@@ -0,0 +1,34 @@
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Email: React.FC<
EmailField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<Input
defaultValue={defaultValue}
id={name}
type="text"
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,5 @@
import * as React from 'react'
export const Error: React.FC = () => {
return <div className="mt-2 text-red-500 text-sm">This field is required</div>
}

View File

@@ -0,0 +1,12 @@
import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
export const Message: React.FC = ({ message }: { message: Record<string, any> }) => {
return (
<Width className="my-12" width="100">
{message && <RichText content={message} />}
</Width>
)
}

View File

@@ -0,0 +1,32 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Number: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<Input
defaultValue={defaultValue}
id={name}
type="number"
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,60 @@
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select as SelectComponent,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
export const Select: React.FC<
SelectField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, options, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = options.find((t) => t.value === value)
return (
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{options.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</SelectComponent>
)
}}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,61 @@
import type { StateField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import React from 'react'
import { Controller } from 'react-hook-form'
import { Error } from '../Error'
import { Width } from '../Width'
import { stateOptions } from './options'
export const State: React.FC<
StateField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => {
const controlledValue = stateOptions.find((t) => t.value === value)
return (
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
<SelectTrigger className="w-full" id={name}>
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
{stateOptions.map(({ label, value }) => {
return (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,52 @@
export const stateOptions = [
{ label: 'Alabama', value: 'AL' },
{ label: 'Alaska', value: 'AK' },
{ label: 'Arizona', value: 'AZ' },
{ label: 'Arkansas', value: 'AR' },
{ label: 'California', value: 'CA' },
{ label: 'Colorado', value: 'CO' },
{ label: 'Connecticut', value: 'CT' },
{ label: 'Delaware', value: 'DE' },
{ label: 'Florida', value: 'FL' },
{ label: 'Georgia', value: 'GA' },
{ label: 'Hawaii', value: 'HI' },
{ label: 'Idaho', value: 'ID' },
{ label: 'Illinois', value: 'IL' },
{ label: 'Indiana', value: 'IN' },
{ label: 'Iowa', value: 'IA' },
{ label: 'Kansas', value: 'KS' },
{ label: 'Kentucky', value: 'KY' },
{ label: 'Louisiana', value: 'LA' },
{ label: 'Maine', value: 'ME' },
{ label: 'Maryland', value: 'MD' },
{ label: 'Massachusetts', value: 'MA' },
{ label: 'Michigan', value: 'MI' },
{ label: 'Minnesota', value: 'MN' },
{ label: 'Mississippi', value: 'MS' },
{ label: 'Missouri', value: 'MO' },
{ label: 'Montana', value: 'MT' },
{ label: 'Nebraska', value: 'NE' },
{ label: 'Nevada', value: 'NV' },
{ label: 'New Hampshire', value: 'NH' },
{ label: 'New Jersey', value: 'NJ' },
{ label: 'New Mexico', value: 'NM' },
{ label: 'New York', value: 'NY' },
{ label: 'North Carolina', value: 'NC' },
{ label: 'North Dakota', value: 'ND' },
{ label: 'Ohio', value: 'OH' },
{ label: 'Oklahoma', value: 'OK' },
{ label: 'Oregon', value: 'OR' },
{ label: 'Pennsylvania', value: 'PA' },
{ label: 'Rhode Island', value: 'RI' },
{ label: 'South Carolina', value: 'SC' },
{ label: 'South Dakota', value: 'SD' },
{ label: 'Tennessee', value: 'TN' },
{ label: 'Texas', value: 'TX' },
{ label: 'Utah', value: 'UT' },
{ label: 'Vermont', value: 'VT' },
{ label: 'Virginia', value: 'VA' },
{ label: 'Washington', value: 'WA' },
{ label: 'West Virginia', value: 'WV' },
{ label: 'Wisconsin', value: 'WI' },
{ label: 'Wyoming', value: 'WY' },
]

View File

@@ -0,0 +1,33 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Text: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<Input
defaultValue={defaultValue}
id={name}
type="text"
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,45 @@
import type { TextField } from '@payloadcms/plugin-form-builder/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import { Label } from '@/components/ui/label'
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
export const Textarea: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues>
rows?: number
}
> = ({
name,
defaultValue,
errors,
label,
register,
required: requiredFromProps,
rows = 3,
width,
}) => {
return (
<Width width={width}>
<Label htmlFor={name}>{label}</Label>
<TextAreaComponent
defaultValue={defaultValue}
id={name}
rows={rows}
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</Width>
)
}

View File

@@ -0,0 +1,13 @@
import * as React from 'react'
export const Width: React.FC<{
children: React.ReactNode
className?: string
width?: number | string
}> = ({ children, className, width }) => {
return (
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
{children}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { FormFieldBlock } from '@payloadcms/plugin-form-builder/types'
export const buildInitialFormState = (fields: FormFieldBlock[]) => {
return fields?.reduce((initialSchema, field) => {
if (field.blockType === 'checkbox') {
return {
...initialSchema,
[field.name]: field.defaultValue,
}
}
if (field.blockType === 'country') {
return {
...initialSchema,
[field.name]: '',
}
}
if (field.blockType === 'email') {
return {
...initialSchema,
[field.name]: '',
}
}
if (field.blockType === 'text') {
return {
...initialSchema,
[field.name]: '',
}
}
if (field.blockType === 'select') {
return {
...initialSchema,
[field.name]: '',
}
}
if (field.blockType === 'state') {
return {
...initialSchema,
[field.name]: '',
}
}
}, {})
}

View File

@@ -0,0 +1,52 @@
import type { Block } from 'payload'
import {
FixedToolbarFeature,
HeadingFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const FormBlock: Block = {
slug: 'formBlock',
interfaceName: 'FormBlock',
fields: [
{
name: 'form',
type: 'relationship',
relationTo: 'forms',
required: true,
},
{
name: 'enableIntro',
type: 'checkbox',
label: 'Enable Intro Content',
},
{
name: 'introContent',
type: 'richText',
localized: true,
admin: {
condition: (_, { enableIntro }) => Boolean(enableIntro),
},
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: 'Intro Content',
},
],
graphQL: {
singularName: 'FormBlock',
},
labels: {
plural: 'Form Blocks',
singular: 'Form Block',
},
}

View File

@@ -0,0 +1,21 @@
import { Checkbox } from './Checkbox'
import { Country } from './Country'
import { Email } from './Email'
import { Message } from './Message'
import { Number } from './Number'
import { Select } from './Select'
import { State } from './State'
import { Text } from './Text'
import { Textarea } from './Textarea'
export const fields = {
checkbox: Checkbox,
country: Country,
email: Email,
message: Message,
number: Number,
select: Select,
state: State,
text: Text,
textarea: Textarea,
}

View File

@@ -0,0 +1,70 @@
import type { StaticImageData } from 'next/image'
import { cn } from 'src/utilities/cn'
import React from 'react'
import RichText from '@/components/RichText'
import type { Page } from '@/payload-types'
import { Media } from '../../components/Media'
type Props = Extract<Page['layout'][0], { blockType: 'mediaBlock' }> & {
breakout?: boolean
captionClassName?: string
className?: string
enableGutter?: boolean
id?: string
imgClassName?: string
staticImage?: StaticImageData
disableInnerContainer?: boolean
}
export const MediaBlock: React.FC<Props> = (props) => {
const {
captionClassName,
className,
enableGutter = true,
imgClassName,
media,
position = 'default',
staticImage,
disableInnerContainer,
} = props
let caption
if (media && typeof media === 'object') caption = media.caption
return (
<div
className={cn(
'',
{
container: position === 'default' && enableGutter,
},
className,
)}
>
{position === 'fullscreen' && (
<div className="relative">
<Media resource={media} src={staticImage} />
</div>
)}
{position === 'default' && (
<Media imgClassName={cn('rounded', imgClassName)} resource={media} src={staticImage} />
)}
{caption && (
<div
className={cn(
'mt-6',
{
container: position === 'fullscreen' && !disableInnerContainer,
},
captionClassName,
)}
>
<RichText content={caption} enableGutter={false} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import type { Block } from 'payload'
export const MediaBlock: Block = {
slug: 'mediaBlock',
interfaceName: 'MediaBlock',
fields: [
{
name: 'position',
type: 'select',
defaultValue: 'default',
options: [
{
label: 'Default',
value: 'default',
},
{
label: 'Fullscreen',
value: 'fullscreen',
},
],
},
{
name: 'media',
type: 'upload',
relationTo: 'media',
required: true,
},
],
}

View File

@@ -0,0 +1,31 @@
import clsx from 'clsx'
import React from 'react'
import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { Card } from '../../components/Card'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: any
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
const { className, docs, introContent } = props
return (
<div className={clsx('container', className)}>
{introContent && <RichText content={introContent} enableGutter={false} />}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8 items-stretch">
{docs?.map((doc, index) => {
if (typeof doc === 'string') return null
return <Card key={index} doc={doc} relationTo="posts" showCategories />
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { cn } from 'src/utilities/cn'
import React, { Fragment } from 'react'
import type { Page } from '@/payload-types'
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
import { ContentBlock } from '@/blocks/Content/Component'
import { FormBlock } from '@/blocks/Form/Component'
import { MediaBlock } from '@/blocks/MediaBlock/Component'
import { TypedLocale } from 'payload'
const blockComponents = {
archive: ArchiveBlock,
content: ContentBlock,
cta: CallToActionBlock,
formBlock: FormBlock,
mediaBlock: MediaBlock,
}
export const RenderBlocks: React.FC<{
blocks: Page['layout'][0][]
locale: TypedLocale
}> = (props) => {
const { blocks, locale } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (hasBlocks) {
return (
<Fragment>
{blocks.map((block, index) => {
const { blockType } = block
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
if (Block) {
return (
<div className="my-16" key={index}>
{/* @ts-expect-error */}
<Block {...block} locale={locale} />
</div>
)
}
}
return null
})}
</Fragment>
)
}
return null
}

View File

@@ -0,0 +1,27 @@
import type { CollectionConfig } from 'payload'
import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated'
const Categories: CollectionConfig = {
slug: 'categories',
access: {
create: authenticated,
delete: authenticated,
read: anyone,
update: authenticated,
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
localized: true,
required: true,
},
],
}
export default Categories

View File

@@ -0,0 +1,47 @@
import type { CollectionConfig } from 'payload'
import {
FixedToolbarFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const Media: CollectionConfig = {
slug: 'media',
access: {
create: authenticated,
delete: authenticated,
read: anyone,
update: authenticated,
},
fields: [
{
name: 'alt',
type: 'text',
localized: true,
required: true,
},
{
name: 'caption',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
},
}),
},
],
upload: {
// Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload
staticDir: path.resolve(dirname, '../../public/media'),
},
}

View File

@@ -0,0 +1,30 @@
import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath } from 'next/cache'
import type { Page } from '../../../payload-types'
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
doc,
previousDoc,
req: { payload },
}) => {
if (doc._status === 'published') {
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
payload.logger.info(`Revalidating page at path: ${path}`)
revalidatePath(path)
}
// If the page was previously published, we need to revalidate the old path
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
revalidatePath(oldPath)
}
return doc
}

View File

@@ -0,0 +1,131 @@
import type { CollectionConfig, TypedLocale } from 'payload'
import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { Archive } from '../../blocks/ArchiveBlock/config'
import { CallToAction } from '../../blocks/CallToAction/config'
import { Content } from '../../blocks/Content/config'
import { FormBlock } from '../../blocks/Form/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { hero } from '@/heros/config'
import { slugField } from '@/fields/slug'
import { populatePublishedAt } from '../../hooks/populatePublishedAt'
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { revalidatePage } from './hooks/revalidatePage'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
export const Pages: CollectionConfig = {
slug: 'pages',
access: {
create: authenticated,
delete: authenticated,
read: authenticatedOrPublished,
update: authenticated,
},
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, locale }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
collection: 'pages',
locale: locale.code,
})
return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}`
},
},
preview: (data, { locale }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
collection: 'pages',
locale,
})
return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}`
},
useAsTitle: 'title',
},
fields: [
{
name: 'title',
localized: true,
type: 'text',
required: true,
},
{
type: 'tabs',
tabs: [
{
fields: [hero],
label: 'Hero',
},
{
fields: [
{
name: 'layout',
type: 'blocks',
localized: true,
blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock],
required: true,
},
],
label: 'Content',
},
{
name: 'meta',
label: 'SEO',
fields: [
OverviewField({
titlePath: 'meta.title',
descriptionPath: 'meta.description',
imagePath: 'meta.image',
}),
MetaTitleField({
hasGenerateFn: true,
}),
MetaImageField({
relationTo: 'media',
}),
MetaDescriptionField({}),
PreviewField({
// if the `generateUrl` function is configured
hasGenerateFn: true,
// field paths to match the target field for data
titlePath: 'meta.title',
descriptionPath: 'meta.description',
}),
],
},
],
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
},
},
...slugField(),
],
hooks: {
afterChange: [revalidatePage],
beforeChange: [populatePublishedAt],
},
versions: {
drafts: {
autosave: {
interval: 100, // We set this interval for optimal live preview
},
},
maxPerDoc: 50,
},
}

View File

@@ -0,0 +1,30 @@
import type { CollectionAfterReadHook } from 'payload'
import { User } from 'src/payload-types'
// The `user` collection has access control locked so that users are not publicly accessible
// This means that we need to populate the authors manually here to protect user privacy
// GraphQL will not return mutated user data that differs from the underlying schema
// So we use an alternative `populatedAuthors` field to populate the user data, hidden from the admin UI
export const populateAuthors: CollectionAfterReadHook = async ({ doc, req, req: { payload } }) => {
if (doc?.authors) {
const authorDocs: User[] = []
for (const author of doc.authors) {
const authorDoc = await payload.findByID({
id: typeof author === 'object' ? author?.id : author,
collection: 'users',
depth: 0,
req,
})
authorDocs.push(authorDoc)
}
doc.populatedAuthors = authorDocs.map((authorDoc) => ({
id: authorDoc.id,
name: authorDoc.name,
}))
}
return doc
}

View File

@@ -0,0 +1,30 @@
import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath } from 'next/cache'
import type { Post } from '../../../payload-types'
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
doc,
previousDoc,
req: { payload },
}) => {
if (doc._status === 'published') {
const path = `/posts/${doc.slug}`
payload.logger.info(`Revalidating post at path: ${path}`)
revalidatePath(path)
}
// If the post was previously published, we need to revalidate the old path
if (previousDoc._status === 'published' && doc._status !== 'published') {
const oldPath = `/posts/${previousDoc.slug}`
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
revalidatePath(oldPath)
}
return doc
}

View File

@@ -0,0 +1,222 @@
import type { CollectionConfig, TypedLocale } from 'payload'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { populateAuthors } from './hooks/populateAuthors'
import { revalidatePost } from './hooks/revalidatePost'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: authenticated,
delete: authenticated,
read: authenticatedOrPublished,
update: authenticated,
},
admin: {
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data, locale }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
collection: 'posts',
locale: locale.code,
})
return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}`
},
},
preview: (data, { locale }) => {
const path = generatePreviewPath({
slug: typeof data?.slug === 'string' ? data.slug : '',
collection: 'posts',
locale,
})
return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}`
},
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
localized: true,
required: true,
},
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'content',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BlocksFeature({ blocks: [Banner, Code, MediaBlock] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
]
},
}),
label: false,
required: true,
},
],
label: 'Content',
},
{
fields: [
{
name: 'relatedPosts',
type: 'relationship',
admin: {
position: 'sidebar',
},
filterOptions: ({ id }) => {
return {
id: {
not_in: [id],
},
}
},
hasMany: true,
relationTo: 'posts',
},
{
name: 'categories',
type: 'relationship',
admin: {
position: 'sidebar',
},
hasMany: true,
relationTo: 'categories',
},
],
label: 'Meta',
},
{
name: 'meta',
label: 'SEO',
fields: [
OverviewField({
titlePath: 'meta.title',
descriptionPath: 'meta.description',
imagePath: 'meta.image',
}),
MetaTitleField({
hasGenerateFn: true,
}),
MetaImageField({
relationTo: 'media',
}),
MetaDescriptionField({}),
PreviewField({
// if the `generateUrl` function is configured
hasGenerateFn: true,
// field paths to match the target field for data
titlePath: 'meta.title',
descriptionPath: 'meta.description',
}),
],
},
],
},
{
name: 'publishedAt',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
position: 'sidebar',
},
hooks: {
beforeChange: [
({ siblingData, value }) => {
if (siblingData._status === 'published' && !value) {
return new Date()
}
return value
},
],
},
},
{
name: 'authors',
type: 'relationship',
admin: {
position: 'sidebar',
},
hasMany: true,
relationTo: 'users',
},
// This field is only used to populate the user data via the `populateAuthors` hook
// This is because the `user` collection has access control locked to protect user privacy
// GraphQL will also not return mutated user data that differs from the underlying schema
{
name: 'populatedAuthors',
type: 'array',
access: {
update: () => false,
},
admin: {
disabled: true,
readOnly: true,
},
fields: [
{
name: 'id',
type: 'text',
},
{
name: 'name',
type: 'text',
},
],
},
...slugField(),
],
hooks: {
afterChange: [revalidatePost],
afterRead: [populateAuthors],
},
versions: {
drafts: {
autosave: {
interval: 100, // We set this interval for optimal live preview
},
},
maxPerDoc: 50,
},
}

View File

@@ -0,0 +1,28 @@
import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated'
const Users: CollectionConfig = {
slug: 'users',
access: {
admin: authenticated,
create: authenticated,
delete: authenticated,
read: authenticated,
update: authenticated,
},
admin: {
defaultColumns: ['name', 'email'],
useAsTitle: 'name',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
},
],
timestamps: true,
}
export default Users

View File

@@ -0,0 +1,7 @@
@import '~@payloadcms/ui/scss';
.admin-bar {
@include small-break {
display: none;
}
}

View File

@@ -0,0 +1,85 @@
'use client'
import type { PayloadAdminBarProps } from 'payload-admin-bar'
import { cn } from '@/utilities/cn'
import { useSelectedLayoutSegments } from 'next/navigation'
import { PayloadAdminBar } from 'payload-admin-bar'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import './index.scss'
import { useTranslations } from 'next-intl'
const baseClass = 'admin-bar'
const collectionLabels = {
pages: {
plural: 'Pages',
singular: 'Page',
},
posts: {
plural: 'Posts',
singular: 'Post',
},
projects: {
plural: 'Projects',
singular: 'Project',
},
}
export const AdminBar: React.FC<{
adminBarProps?: PayloadAdminBarProps
}> = (props) => {
const { adminBarProps } = props || {}
const segments = useSelectedLayoutSegments()
const [show, setShow] = useState(false)
const collection = collectionLabels?.[segments?.[1]] ? segments?.[1] : 'pages'
const router = useRouter()
const t = useTranslations()
const onAuthChange = React.useCallback((user) => {
setShow(user?.id)
}, [])
return (
<div
className={cn(baseClass, 'py-2 bg-black text-white', {
block: show,
hidden: !show,
})}
>
<div className="container">
<PayloadAdminBar
{...adminBarProps}
className="py-2 text-white"
classNames={{
controls: 'font-medium text-white',
logo: 'text-white',
user: 'text-white',
}}
cmsURL={process.env.NEXT_PUBLIC_SERVER_URL}
collection={collection}
collectionLabels={{
plural: collectionLabels[collection]?.plural || 'Pages',
singular: collectionLabels[collection]?.singular || 'Page',
}}
logo={<span>{t('dashboard')}</span>}
onAuthChange={onAuthChange}
onPreviewExit={() => {
fetch('/next/exit-preview').then(() => {
router.push('/')
router.refresh()
})
}}
style={{
backgroundColor: 'transparent',
padding: 0,
position: 'relative',
zIndex: 'unset',
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from '@payloadcms/ui'
const SuccessMessage: React.FC = () => (
<div>
Database seeded! You can now{' '}
<a target="_blank" href="/">
visit your website
</a>
</div>
)
export const SeedButton: React.FC = () => {
const [loading, setLoading] = useState(false)
const [seeded, setSeeded] = useState(false)
const [error, setError] = useState(null)
const handleClick = useCallback(
async (e) => {
e.preventDefault()
if (loading || seeded) return
setLoading(true)
try {
await fetch('/api/seed')
setSeeded(true)
toast.success(<SuccessMessage />, { duration: 5000 })
} catch (err) {
setError(err)
}
},
[loading, seeded],
)
let message = ''
if (loading) message = ' (seeding...)'
if (seeded) message = ' (done!)'
if (error) message = ` (error: ${error})`
return (
<Fragment>
<a href="/api/seed" onClick={handleClick} rel="noopener noreferrer" target="_blank">
Reset / seed database
</a>
{message}
</Fragment>
)
}
export default SeedButton

View File

@@ -0,0 +1,14 @@
import React from 'react'
const BeforeLogin: React.FC = () => {
return (
<div>
<p>
<b>Welcome to your dashboard!</b>
{' This is where site admins will log in to manage your website.'}
</p>
</div>
)
}
export default BeforeLogin

View File

@@ -0,0 +1,82 @@
'use client'
import { cn } from '@/utilities/cn'
import useClickableCard from '@/utilities/useClickableCard'
import Link from 'next/link'
import React, { Fragment } from 'react'
import type { Post } from '@/payload-types'
import { Media } from '@/components/Media'
export const Card: React.FC<{
alignItems?: 'center'
className?: string
doc?: Post
relationTo?: 'posts'
showCategories?: boolean
title?: string
}> = (props) => {
const { card, link } = useClickableCard({})
const { className, doc, relationTo, showCategories, title: titleFromProps } = props
const { slug, categories, meta, title } = doc || {}
const { description, image: metaImage } = meta || {}
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
const titleToUse = titleFromProps || title
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
const href = `/${relationTo}/${slug}`
return (
<article
className={cn(
'border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer',
className,
)}
ref={card.ref}
>
<div className="relative w-full ">
{!metaImage && <div className="">No image</div>}
{metaImage && typeof metaImage !== 'string' && <Media resource={metaImage} size="360px" />}
</div>
<div className="p-4">
{showCategories && hasCategories && (
<div className="uppercase text-sm mb-4">
{showCategories && hasCategories && (
<div>
{categories?.map((category, index) => {
if (typeof category === 'object') {
const { title: titleFromCategory } = category
const categoryTitle = titleFromCategory || 'Untitled category'
const isLast = index === categories.length - 1
return (
<Fragment key={index}>
{categoryTitle}
{!isLast && <Fragment>, &nbsp;</Fragment>}
</Fragment>
)
}
return null
})}
</div>
)}
</div>
)}
{titleToUse && (
<div className="prose">
<h3>
<Link className="not-prose" href={href} ref={link.ref}>
{titleToUse}
</Link>
</h3>
</div>
)}
{description && <div className="mt-2">{description && <p>{sanitizedDescription}</p>}</div>}
</div>
</article>
)
}

View File

@@ -0,0 +1,34 @@
import { cn } from 'src/utilities/cn'
import React from 'react'
import type { Post } from '@/payload-types'
import { Card } from '@/components/Card'
export type Props = {
posts: Post[]
}
export const CollectionArchive: React.FC<Props> = (props) => {
const { posts } = props
return (
<div className={cn('container')}>
<div>
<div className="grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-y-4 gap-x-4 lg:gap-y-8 lg:gap-x-8 xl:gap-x-8">
{posts?.map((result, index) => {
if (typeof result === 'object' && result !== null) {
return (
<div className="col-span-4" key={index}>
<Card className="h-full" doc={result} relationTo="posts" showCategories />
</div>
)
}
return null
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Button, type ButtonProps } from '@/components/ui/button'
import { cn } from 'src/utilities/cn'
import { Link as i18nLink } from '@/i18n/routing'
import React from 'react'
import type { Page, Post } from '@/payload-types'
import NextLink from 'next/link'
type CMSLinkType = {
appearance?: 'inline' | ButtonProps['variant']
children?: React.ReactNode
className?: string
label?: string | null
newTab?: boolean | null
reference?: {
relationTo: 'pages' | 'posts'
value: Page | Post | string | number
} | null
size?: ButtonProps['size'] | null
type?: 'custom' | 'reference' | null
url?: string | null
}
export const CMSLink: React.FC<CMSLinkType> = (props) => {
const {
type,
appearance = 'inline',
children,
className,
label,
newTab,
reference,
size: sizeFromProps,
url,
} = props
const href =
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
reference.value.slug
}`
: url
if (!href) return null
const finalHref = href || url || ''
const Link = finalHref.startsWith('/admin') ? NextLink : i18nLink
const size = appearance === 'link' ? 'clear' : sizeFromProps
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
/* Ensure we don't break any styles set by richText */
if (appearance === 'inline') {
return (
<Link className={cn(className)} href={finalHref} {...newTabProps}>
{label && label}
{children && children}
</Link>
)
}
return (
<Button asChild className={className} size={size} variant={appearance}>
<Link className={cn(className)} href={finalHref} {...newTabProps}>
{label && label}
{children && children}
</Link>
</Button>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'
import React from 'react'
export const LivePreviewListener: React.FC = () => {
const router = useRouter()
return (
<PayloadLivePreview refresh={router.refresh} serverURL={process.env.NEXT_PUBLIC_SERVER_URL!} />
)
}

View File

@@ -0,0 +1,12 @@
import React from 'react'
export const Logo = () => {
return (
/* eslint-disable @next/next/no-img-element */
<img
alt="Payload Logo"
className="max-w-[9.375rem] invert dark:invert-0"
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
/>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { StaticImageData } from 'next/image'
import { cn } from 'src/utilities/cn'
import NextImage from 'next/image'
import React from 'react'
import type { Props as MediaProps } from '../types'
import cssVariables from '@/cssVariables'
const { breakpoints } = cssVariables
export const ImageMedia: React.FC<MediaProps> = (props) => {
const {
alt: altFromProps,
fill,
imgClassName,
onClick,
onLoad: onLoadFromProps,
priority,
resource,
size: sizeFromProps,
src: srcFromProps,
} = props
const [isLoading, setIsLoading] = React.useState(true)
let width: number | undefined
let height: number | undefined
let alt = altFromProps
let src: StaticImageData | string = srcFromProps || ''
if (!src && resource && typeof resource === 'object') {
const {
alt: altFromResource,
filename: fullFilename,
height: fullHeight,
url,
width: fullWidth,
} = resource
width = fullWidth!
height = fullHeight!
alt = altFromResource
src = `${process.env.NEXT_PUBLIC_SERVER_URL}${url}`
}
// NOTE: this is used by the browser to determine which image to download at different screen sizes
const sizes = sizeFromProps
? sizeFromProps
: Object.entries(breakpoints)
.map(([, value]) => `(max-width: ${value}px) ${value}px`)
.join(', ')
return (
<NextImage
alt={alt || ''}
className={cn(imgClassName)}
fill={fill}
height={!fill ? height : undefined}
onClick={onClick}
onLoad={() => {
setIsLoading(false)
if (typeof onLoadFromProps === 'function') {
onLoadFromProps()
}
}}
priority={priority}
quality={90}
sizes={sizes}
src={src}
width={!fill ? width : undefined}
/>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { cn } from 'src/utilities/cn'
import React, { useEffect, useRef } from 'react'
import type { Props as MediaProps } from '../types'
export const VideoMedia: React.FC<MediaProps> = (props) => {
const { onClick, resource, videoClassName } = props
const videoRef = useRef<HTMLVideoElement>(null)
// const [showFallback] = useState<boolean>()
useEffect(() => {
const { current: video } = videoRef
if (video) {
video.addEventListener('suspend', () => {
// setShowFallback(true);
// console.warn('Video was suspended, rendering fallback image.')
})
}
}, [])
if (resource && typeof resource === 'object') {
const { filename } = resource
return (
<video
autoPlay
className={cn(videoClassName)}
controls={false}
loop
muted
onClick={onClick}
playsInline
ref={videoRef}
>
<source src={`${process.env.NEXT_PUBLIC_SERVER_URL}/media/${filename}`} />
</video>
)
}
return null
}

View File

@@ -0,0 +1,25 @@
import React, { Fragment } from 'react'
import type { Props } from './types'
import { ImageMedia } from './ImageMedia'
import { VideoMedia } from './VideoMedia'
export const Media: React.FC<Props> = (props) => {
const { className, htmlElement = 'div', resource } = props
const isVideo = typeof resource === 'object' && resource?.mimeType?.includes('video')
const Tag = (htmlElement as any) || Fragment
return (
<Tag
{...(htmlElement !== null
? {
className,
}
: {})}
>
{isVideo ? <VideoMedia {...props} /> : <ImageMedia {...props} />}
</Tag>
)
}

Some files were not shown because too many files have changed in this diff Show More