feat: new cms app (#35999)

* add cms turbo app

* add cms scripts to package.json

* update README

* update README

* update files

* fix formatting

* fix populateAuthors

* sync dependencies

* update launch week field

* update lint config

* Coordinate the deps to be the same as the other apps.

* Remove extra files.

* Run prettier on the CMS files.

* Add commands for cleaning and typechecking.

* Fix weird version of @types/react in docs.

* run pnpm install

* fix pnpm-lock

* fix cms lint

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Francesco Sansalvadore
2025-06-03 13:26:43 +02:00
committed by GitHub
parent e65655981c
commit f7d90bbc30
155 changed files with 23109 additions and 222 deletions

View File

@@ -23,3 +23,8 @@ apps/ui-library/public/r
apps/**/.contentlayer
# invalid JSON file
packages/ui/src/components/Form/examples/PhoneProvidersSchema.json
apps/cms/config/api.ts
# files auto-generated by payload cms
apps/cms/src/app/*
apps/cms/src/migrations/*
apps/cms/src/payload-types.ts

13
apps/cms/.env.example Normal file
View File

@@ -0,0 +1,13 @@
DATABASE_URI=postgres://postgres:<password>@127.0.0.1:5432/your-database-name
PAYLOAD_SECRET=YOUR_SECRET_HERE
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_ENDPOINT=
NEXT_PUBLIC_SERVER_URL=http://localhost:3000/blog
CRON_SECRET=YOUR_CRON_SECRET_HERE
PREVIEW_SECRET=YOUR_SECRET_HERE
BLOG_APP_URL=http://localhost:3000

3
apps/cms/.eslintrc.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['eslint-config-supabase/next'],
}

43
apps/cms/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
/media

71
apps/cms/Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file.
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:22.12.0-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Remove this line if you do not have this folder
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

23
apps/cms/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Payload CMS
### Local Development
1. run `cd apps/cms && supabase start` to start the local supabase project
2. run `cp .env.example .env` to copy the example environment variables and update the variables. You'll need to add the `S3_` variables to your `.env` to use Supabase Storage
3. `pnpm install && pnpm generate:importmap` to install dependencies and start the dev server
4. run `pnpm dev` in the apps/cms folder or `pnpm dev:cms` from the root
5. open `http://localhost:3030` to open the app in your browser
Follow the on-screen instructions to login and create the first admin user.
### Collections
Collections are what data looks like in the Payload cms schema. The following are the collections currently configured in the app.
- Authors
- Categories
- Events
- Media
- Posts
- Tags
- Users

View File

@@ -0,0 +1,30 @@
version: '3'
services:
payload:
image: node:18-alpine
ports:
- '3030:3030'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
depends_on:
- postgres
env_file:
- .env
# Uncomment the following to use postgres
postgres:
restart: always
image: postgres:latest
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
data:
# pgdata:
node_modules:

31
apps/cms/next.config.mjs Normal file
View File

@@ -0,0 +1,31 @@
import { withPayload } from '@payloadcms/next/withPayload'
import redirects from './redirects.js'
const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: undefined || 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,
eslint: {
// We are already running linting via GH action, this will skip linting during production build on Vercel
ignoreDuringBuilds: true,
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

47
apps/cms/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "cms",
"version": "1.0.0",
"description": "Payload CMS for Supabase",
"license": "MIT",
"scripts": {
"build": "cross-env NODE_OPTIONS=--no-deprecation next build --turbopack",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --turbopack --port 3030",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"ci": "cross-env NODE_OPTIONS=--no-deprecation payload migrate && pnpm build",
"clean": "rimraf node_modules .next",
"typecheck_IGNORED": "tsc --noEmit"
},
"dependencies": {
"@payloadcms/db-postgres": "3.33.0",
"@payloadcms/live-preview-react": "^3.33.0",
"@payloadcms/next": "3.33.0",
"@payloadcms/payload-cloud": "3.33.0",
"@payloadcms/plugin-form-builder": "3.33.0",
"@payloadcms/plugin-nested-docs": "3.33.0",
"@payloadcms/plugin-seo": "3.33.0",
"@payloadcms/richtext-lexical": "3.33.0",
"@payloadcms/storage-s3": "3.33.0",
"@payloadcms/ui": "3.33.0",
"common": "workspace:*",
"config": "workspace:*",
"cross-env": "^7.0.3",
"eslint-config-supabase": "workspace:*",
"graphql": "^16.8.1",
"next": "~15.3.0",
"payload": "3.33.0",
"react": "catalog:",
"react-dom": "catalog:",
"sharp": "0.32.6"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "5.7.3"
}
}

20
apps/cms/redirects.js Normal file
View File

@@ -0,0 +1,20 @@
const redirects = async () => {
const internetExplorerRedirect = {
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
}
const redirects = [internetExplorerRedirect]
return redirects
}
export default redirects

View File

@@ -0,0 +1,14 @@
import type { AccessArgs, FieldAccess } from 'payload'
import type { User } from '@/payload-types'
type isAdmin = (args: AccessArgs<User>) => boolean
export const isAdmin: isAdmin = ({ req: { user } }) => {
// Return true or false based on if the user has an admin role
return Boolean(user?.roles?.includes('admin'))
}
export const isAdminFieldLevel: FieldAccess<{ id: string }, User> = ({ req: { user } }) => {
// Return true or false based on if the user has an admin role
return Boolean(user?.roles?.includes('admin'))
}

View File

@@ -0,0 +1,21 @@
import type { Access } from 'payload'
export const isAdminOrSelf: Access = ({ req: { user } }) => {
// Need to be logged in
if (user) {
// If user has role of 'admin'
if (user.roles?.includes('admin')) {
return true
}
// If any other type of user, only provide access to themselves
return {
id: {
equals: user.id,
},
}
}
// Reject everyone else
return false
}

View File

@@ -0,0 +1,3 @@
import type { Access } from 'payload'
export const isAnyone: 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 isAuthenticated: isAuthenticated = ({ req: { user } }) => {
return Boolean(user)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import './styles.css'
export const metadata = {
description: 'Content Management System for the Supabase website',
title: 'Supabase CMS',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default async function HomePage() {
redirect('/admin')
}

View File

@@ -0,0 +1,164 @@
:root {
--font-mono: 'Roboto Mono', monospace;
}
* {
box-sizing: border-box;
}
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: system-ui;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
}
}

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,43 @@
import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_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 { HeadingFeatureClient as HeadingFeatureClient_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 { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
export const importMap = {
"@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_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/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_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,
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24
}

View File

@@ -0,0 +1,19 @@
/* 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 '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} 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 PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
/* 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 '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* 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, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@@ -0,0 +1,31 @@
/* 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 '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -0,0 +1,14 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async () => {
const payload = await getPayload({
config: configPromise,
})
const data = await payload.find({
collection: 'users',
})
return Response.json(data)
}

View File

@@ -0,0 +1,65 @@
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'
export const ArchiveBlock: React.FC<
ArchiveBlockProps & {
id?: string
}
> = async (props) => {
const { id, categories, introContent, limit: limitFromProps, populateBy, selectedDocs } = 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,
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="ms-0 max-w-[48rem]" data={introContent} enableGutter={false} />
</div>
)}
<CollectionArchive posts={posts} />
</div>
)
}

View File

@@ -0,0 +1,94 @@
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',
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 '@/utilities/ui'
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 data={content} enableGutter={false} enableProse={false} />
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
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',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
},
}),
label: false,
required: true,
},
],
interfaceName: 'BannerBlock',
}

View File

@@ -0,0 +1,23 @@
import React from 'react'
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
import RichText from '@/components/RichText'
import { CMSLink } from '@/components/Link'
export const CallToActionBlock: React.FC<CTABlockProps> = ({ 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" data={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,42 @@
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',
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,33 @@
'use client'
import { Highlight, themes } from 'prism-react-renderer'
import React from 'react'
import { CopyButton } from './CopyButton'
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>
))}
<CopyButton code={code} />
</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 @@
'use client'
import { Button } from '@/components/ui/button'
import { CopyIcon } from '@payloadcms/ui/icons/Copy'
import { useState } from 'react'
export function CopyButton({ code }: { code: string }) {
const [text, setText] = useState('Copy')
function updateCopyStatus() {
if (text === 'Copy') {
setText(() => 'Copied!')
setTimeout(() => {
setText(() => 'Copy')
}, 1000)
}
}
return (
<div className="flex justify-end align-middle">
<Button
className="flex gap-1"
variant={'secondary'}
onClick={async () => {
await navigator.clipboard.writeText(code)
updateCopyStatus()
}}
>
<p>{text}</p>
<CopyIcon />
</Button>
</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,43 @@
import { cn } from '@/utilities/ui'
import React from 'react'
import RichText from '@/components/RichText'
import type { ContentBlock as ContentBlockProps } from '@/payload-types'
import { CMSLink } from '../../components/Link'
export const ContentBlock: React.FC<ContentBlockProps> = (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 data={richText} enableGutter={false} />}
{enableLink && <CMSLink {...link} />}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
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',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_data, siblingData) => {
return Boolean(siblingData?.enableLink)
},
},
},
}),
]
export const Content: Block = {
slug: 'content',
interfaceName: 'ContentBlock',
fields: [
{
name: 'columns',
type: 'array',
admin: {
initCollapsed: true,
},
fields: columnFields,
},
],
}

View File

@@ -0,0 +1,45 @@
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>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
const props = register(name, { required: required })
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}>
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
{label}
</Label>
</div>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import type { FormFieldBlock, 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 type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { fields } from './fields'
import { getClientSideURL } from '@/utilities/getURL'
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: SerializedEditorState
}
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: 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 onSubmit = useCallback(
(data: FormFieldBlock[]) => {
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(`${getClientSideURL()}/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]">
{enableIntro && introContent && !hasSubmitted && (
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
)}
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
<FormProvider {...formMethods}>
{!isLoading && hasSubmitted && confirmationType === 'message' && (
<RichText data={confirmationMessage} />
)}
{isLoading && !hasSubmitted && <p>Loading, please wait...</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 as keyof typeof fields]
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>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } 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
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label className="" htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</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 }}
/>
{errors[name] && <Error name={name} />}
</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,38 @@
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>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input
defaultValue={defaultValue}
id={name}
type="text"
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@@ -0,0 +1,15 @@
'use client'
import * as React from 'react'
import { useFormContext } from 'react-hook-form'
export const Error = ({ name }: { name: string }) => {
const {
formState: { errors },
} = useFormContext()
return (
<div className="mt-2 text-red-500 text-sm">
{(errors[name]?.message as string) || 'This field is required'}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
export const Message: React.FC<{ message: SerializedEditorState }> = ({ message }) => {
return (
<Width className="my-12" width="100">
{message && <RichText data={message} />}
</Width>
)
}

View File

@@ -0,0 +1,36 @@
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>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input
defaultValue={defaultValue}
id={name}
type="number"
{...register(name, { required })}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@@ -0,0 +1,63 @@
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } 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
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Controller
control={control}
defaultValue={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 }}
/>
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@@ -0,0 +1,64 @@
import type { StateField } from '@payloadcms/plugin-form-builder/types'
import type { Control, FieldErrorsImpl } 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
errors: Partial<FieldErrorsImpl>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</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 }}
/>
{errors[name] && <Error name={name} />}
</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,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 Text: React.FC<
TextField & {
errors: Partial<FieldErrorsImpl>
register: UseFormRegister<FieldValues>
}
> = ({ name, defaultValue, errors, label, register, required, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
{errors[name] && <Error name={name} />}
</Width>
)
}

View File

@@ -0,0 +1,40 @@
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>
register: UseFormRegister<FieldValues>
rows?: number
}
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
return (
<Width width={width}>
<Label htmlFor={name}>
{label}
{required && (
<span className="required">
* <span className="sr-only">(required)</span>
</span>
)}
</Label>
<TextAreaComponent
defaultValue={defaultValue}
id={name}
rows={rows}
{...register(name, { required: required })}
/>
{errors[name] && <Error name={name} />}
</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,51 @@
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',
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,67 @@
import type { StaticImageData } from 'next/image'
import { cn } from '@/utilities/ui'
import React from 'react'
import RichText from '@/components/RichText'
import type { MediaBlock as MediaBlockProps } from '@/payload-types'
import { Media } from '../../components/Media'
type Props = MediaBlockProps & {
breakout?: boolean
captionClassName?: string
className?: string
enableGutter?: boolean
imgClassName?: string
staticImage?: StaticImageData
disableInnerContainer?: boolean
}
export const MediaBlock: React.FC<Props> = (props) => {
const {
captionClassName,
className,
enableGutter = true,
imgClassName,
media,
staticImage,
disableInnerContainer,
} = props
let caption
if (media && typeof media === 'object') caption = media.caption
return (
<div
className={cn(
'',
{
container: enableGutter,
},
className
)}
>
{(media || staticImage) && (
<Media
imgClassName={cn('border border-border rounded-[0.8rem]', imgClassName)}
resource={media}
src={staticImage}
/>
)}
{caption && (
<div
className={cn(
'mt-6',
{
container: !disableInnerContainer,
},
captionClassName
)}
>
<RichText data={caption} enableGutter={false} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,14 @@
import type { Block } from 'payload'
export const MediaBlock: Block = {
slug: 'mediaBlock',
interfaceName: 'MediaBlock',
fields: [
{
name: 'media',
type: 'upload',
relationTo: 'media',
required: true,
},
],
}

View File

@@ -0,0 +1,16 @@
import type { QuoteBlock as QuoteBlockProps } from 'src/payload-types'
import React from 'react'
type Props = {
className?: string
} & QuoteBlockProps
export const QuoteBlock: React.FC<Props> = ({ className, img, caption, text }) => {
return `<Quote img={${img}} caption={${caption ?? ''}} className={${className}}>
{${text}}
</Quote>
`
}

View File

@@ -0,0 +1,27 @@
import type { Block } from 'payload'
export const Quote: Block = {
slug: 'quote',
fields: [
{
name: 'img',
type: 'upload',
relationTo: 'media',
label: 'Avatar',
required: false,
},
{
name: 'caption',
type: 'text',
label: 'Caption',
required: false,
},
{
name: 'text',
type: 'textarea',
label: 'Quote Text',
required: true,
},
],
interfaceName: 'QuoteBlock',
}

View File

@@ -0,0 +1,32 @@
import clsx from 'clsx'
import React from 'react'
import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { Card } from '../../components/Card'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: SerializedEditorState
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
const { className, docs, introContent } = props
return (
<div className={clsx('lg:container', className)}>
{introContent && <RichText data={introContent} enableGutter={false} />}
<div className="grid grid-cols-1 md:grid-cols-2 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,51 @@
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'
const blockComponents = {
archive: ArchiveBlock,
content: ContentBlock,
cta: CallToActionBlock,
formBlock: FormBlock,
mediaBlock: MediaBlock,
}
export const RenderBlocks: React.FC<{
blocks: Page['layout'][0][]
}> = (props) => {
const { blocks } = 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 there may be some mismatch between the expected types here */}
<Block {...block} disableInnerContainer />
</div>
)
}
}
return null
})}
</Fragment>
)
}
return null
}

View File

@@ -0,0 +1,22 @@
import type { YouTubeBlock as YouTubeBlockProps } from 'src/payload-types'
import { cn } from '@/utilities/ui'
import React from 'react'
type Props = {
className?: string
} & YouTubeBlockProps
export const YouTubeBlock: React.FC<Props> = ({ className, youtubeId }) => {
return (
<div className={cn('video-container', className)}>
<iframe
className="w-full"
src={`https://www.youtube-nocookie.com/embed/${youtubeId}`}
title="YouTube video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import type { Block } from 'payload'
export const YouTube: Block = {
slug: 'youtube',
fields: [
{
name: 'youtubeId',
type: 'text',
label: 'YouTube Video ID',
required: true,
admin: {
description:
'Enter the YouTube video ID (e.g., "dQw4w9WgXcQ" from https://www.youtube.com/watch?v=dQw4w9WgXcQ)',
},
},
],
interfaceName: 'YouTubeBlock',
}

View File

@@ -0,0 +1,43 @@
import { CollectionConfig } from 'payload'
export const Authors: CollectionConfig = {
slug: 'authors',
admin: {
useAsTitle: 'author',
},
access: {
read: () => true,
},
fields: [
{
name: 'author',
type: 'text',
required: true,
},
{
name: 'author_id',
type: 'text',
},
{
name: 'position',
type: 'text',
},
{
name: 'author_url',
type: 'text',
},
{
name: 'author_image_url',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'username',
type: 'text',
},
],
timestamps: true,
}
export default Authors

View File

@@ -0,0 +1,21 @@
import { CollectionConfig } from 'payload'
export const Categories: CollectionConfig = {
slug: 'categories',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
],
timestamps: true,
}
export default Categories

View File

@@ -0,0 +1,47 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { Customer } from '../../../payload-types'
export const revalidateCustomer: CollectionAfterChangeHook<Customer> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = `/customers/${doc.slug}`
payload.logger.info(`Revalidating event at path: ${path}`)
revalidatePath(path)
revalidateTag('customers-sitemap')
}
// If the event was previously published, we need to revalidate the old path
if (previousDoc._status === 'published' && doc._status !== 'published') {
const oldPath = `/customers/${previousDoc.slug}`
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('customers-sitemap')
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Customer> = ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
const path = `/customers/${doc?.slug}`
revalidatePath(path)
revalidateTag('customers-sitemap')
}
return doc
}

View File

@@ -0,0 +1,315 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config'
import { Quote } from '../../blocks/Quote/config'
import { YouTube } from '../../blocks/YouTube/config'
import { revalidateDelete, revalidateCustomer } from './hooks/revalidateCustomer'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
const industryOptions = [
{ label: 'Healthcare', value: 'healthcare' },
{ label: 'Fintech', value: 'fintech' },
{ label: 'E-commerce', value: 'ecommerce' },
{ label: 'Education', value: 'education' },
{ label: 'Gaming', value: 'gaming' },
{ label: 'Media', value: 'media' },
{ label: 'Real Estate', value: 'real-estate' },
{ label: 'SaaS', value: 'saas' },
{ label: 'Social', value: 'social' },
{ label: 'Analytics', value: 'analytics' },
{ label: 'AI', value: 'ai' },
{ label: 'Developer Tools', value: 'developer-tools' },
]
const companySizeOptions = [
{ label: 'Startup', value: 'startup' },
{ label: 'Enterprise', value: 'enterprise' },
{ label: 'Independent Developer', value: 'indie_dev' },
]
const regionOptions = [
{ label: 'Asia', value: 'Asia' },
{ label: 'Europe', value: 'Europe' },
{ label: 'North America', value: 'North America' },
{ label: 'South America', value: 'South America' },
{ label: 'Africa', value: 'Africa' },
{ label: 'Oceania', value: 'Oceania' },
]
const supabaseProductOptions = [
{ label: 'Database', value: 'database' },
{ label: 'Auth', value: 'auth' },
{ label: 'Storage', value: 'storage' },
{ label: 'Realtime', value: 'realtime' },
{ label: 'Functions', value: 'functions' },
{ label: 'Vector', value: 'vector' },
]
export const Customers: CollectionConfig = {
slug: 'customers',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'slug', 'updatedAt'],
preview: (data) => {
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
const isDraft = data?._status === 'draft'
return `${baseUrl}/customers/${data?.slug}${isDraft ? '?preview=true' : ''}`
},
},
access: {
create: isAuthenticated,
delete: isAuthenticated,
read: isAnyone,
update: isAuthenticated,
},
defaultPopulate: {
name: true,
title: true,
slug: true,
categories: true,
meta: {
image: true,
description: true,
},
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'title',
type: 'text',
required: false,
},
...slugField('name'),
{
type: 'tabs',
tabs: [
{
label: 'Content',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
]
},
}),
label: false,
required: true,
},
],
},
{
label: 'Metadata',
fields: [
{
name: 'description',
type: 'text',
},
{
name: 'about',
type: 'textarea',
admin: {
description: 'Short description about the company',
},
},
{
name: 'company_url',
type: 'text',
admin: {
description: 'URL of the company website',
},
},
{
name: 'stats',
type: 'array',
admin: {
description: 'Key statistics or metrics to highlight',
},
fields: [
{
name: 'stat',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
],
},
{
name: 'misc',
type: 'array',
admin: {
description: 'Miscellaneous information (e.g., Founded, Location)',
},
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'text',
type: 'text',
required: true,
},
],
},
{
name: 'industry',
type: 'select',
hasMany: true,
options: industryOptions,
admin: {
description: 'Industry categories',
},
},
{
name: 'company_size',
type: 'select',
options: companySizeOptions,
admin: {
description: 'Size of the company',
},
},
{
name: 'region',
type: 'select',
options: regionOptions,
admin: {
description: 'Geographic region',
},
},
{
name: 'supabase_products',
type: 'select',
hasMany: true,
options: supabaseProductOptions,
admin: {
description: 'Supabase products being used',
},
},
],
},
{
name: 'meta',
label: 'SEO',
fields: [
OverviewField({
titlePath: 'meta.name',
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.name',
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: 'logo',
type: 'upload',
relationTo: 'media',
required: false,
admin: {
position: 'sidebar',
},
},
{
name: 'logo_inverse',
type: 'upload',
relationTo: 'media',
required: false,
admin: {
position: 'sidebar',
description: 'Light mode logo',
},
},
],
timestamps: true,
hooks: {
afterChange: [revalidateCustomer],
afterDelete: [revalidateDelete],
},
versions: {
drafts: {
autosave: {
interval: 100,
},
schedulePublish: true,
},
maxPerDoc: 50,
},
}
export default Customers

View File

@@ -0,0 +1,44 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { Event } from '../../../payload-types'
export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = `/events/${doc.slug}`
payload.logger.info(`Revalidating event at path: ${path}`)
revalidatePath(path)
revalidateTag('events-sitemap')
}
// If the event was previously published, we need to revalidate the old path
if (previousDoc._status === 'published' && doc._status !== 'published') {
const oldPath = `/events/${previousDoc.slug}`
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('events-sitemap')
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Event> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = `/events/${doc?.slug}`
revalidatePath(path)
revalidateTag('events-sitemap')
}
return doc
}

View File

@@ -0,0 +1,405 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { Banner } from '@/blocks/Banner/config'
import { Code } from '@/blocks/Code/config'
import { MediaBlock } from '@/blocks/MediaBlock/config'
import { Quote } from '@/blocks/Quote/config'
import { YouTube } from '@/blocks/YouTube/config'
import { revalidateDelete, revalidateEvent } from './hooks/revalidateEvent'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { timezoneOptions } from '../../utilities/timezones'
const eventTypeOptions = [
{ label: 'Conference', value: 'conference' },
{ label: 'Hackathon', value: 'hackathon' },
{ label: 'Launch Week', value: 'launch-week' },
{ label: 'Meetup', value: 'meetup' },
{ label: 'Office Hours', value: 'office-hours' },
{ label: 'Talk', value: 'talk' },
{ label: 'Webinar', value: 'webinar' },
{ label: 'Workshop', value: 'workshop' },
{ label: 'Other', value: 'other' },
]
export const Events: CollectionConfig = {
slug: 'events',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
preview: (data) => {
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
const isDraft = data?._status === 'draft'
return `${baseUrl}/events/${data?.slug}${isDraft ? '?preview=true' : ''}`
},
},
access: {
create: isAuthenticated,
delete: isAuthenticated,
read: isAnyone,
update: isAuthenticated,
},
defaultPopulate: {
title: true,
slug: true,
categories: true,
meta: {
image: true,
description: true,
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
...slugField(),
{
name: 'subtitle',
type: 'text',
admin: {
description: 'Used in the event page as subtitle.',
},
},
{
type: 'tabs',
tabs: [
{
label: 'Content',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
]
},
}),
label: false,
required: false,
},
],
},
{
label: 'Metadata',
fields: [
{
name: 'thumb',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'type',
type: 'select',
hasMany: true,
options: eventTypeOptions,
admin: {
description: 'Event type',
},
},
{
name: 'date',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'timezone',
type: 'select',
options: timezoneOptions,
},
{
name: 'showEndDate',
type: 'checkbox',
defaultValue: false,
admin: {},
},
{
name: 'endDate',
type: 'date',
admin: {
description:
'If "showEndDate" is true, this will define when the event terminates.',
condition: (data) => {
return data.showEndDate
},
},
},
{
name: 'duration',
type: 'text',
admin: {
description:
'Text string to display on the event page to indicate the duration of the event. (e.g. "45 mins", "2 days")',
},
},
{
name: 'onDemand',
type: 'checkbox',
defaultValue: false,
admin: {
description:
'Events that are will remain available on the events page after the event has ended.',
},
},
{
name: 'disablePageBuild',
type: 'checkbox',
defaultValue: false,
admin: {
description:
'When true, the event page will not be built. It will link directly to an external event page (requires Link to be set)',
},
},
{
name: 'link',
type: 'group',
admin: {
description:
'Used on event previews to link to a custom page if "disablePageBuild" is true.',
condition: (data) => {
return data.disablePageBuild
},
},
fields: [
{
name: 'href',
type: 'text',
required: false,
},
{
name: 'target',
type: 'select',
options: [
{ label: 'Same window', value: '_self' },
{ label: 'New window', value: '_blank' },
],
defaultValue: '_blank',
},
],
},
{
name: 'mainCta',
type: 'group',
admin: {
description: 'Main CTA button on the event page',
},
fields: [
{
name: 'href',
type: 'text',
required: false,
},
{
name: 'target',
type: 'select',
options: [
{ label: 'Same window', value: '_self' },
{ label: 'New window', value: '_blank' },
],
defaultValue: '_blank',
},
{
name: 'label',
type: 'text',
},
{
name: 'disabled',
type: 'checkbox',
defaultValue: false,
},
{
name: 'disabled_label',
type: 'text',
admin: {
description: 'Text for the main CTA button if "mainCta.disabled" is true.',
condition: (data) => data.mainCta.disabled,
},
},
],
},
],
},
{
label: 'Participants',
fields: [
{
name: 'company',
type: 'group',
fields: [
{
name: 'showCompany',
type: 'checkbox',
defaultValue: false,
admin: {
description:
'If an external company is collaborating with the event, this will display their logo on the event page.',
},
},
{
name: 'name',
type: 'text',
required: false,
admin: {
condition: (data) => data.company.showCompany,
},
},
{
name: 'websiteUrl',
type: 'text',
admin: {
condition: (data) => data.company.showCompany,
},
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
required: false,
admin: {
condition: (data) => data.company.showCompany,
},
},
{
name: 'logo_light',
type: 'upload',
relationTo: 'media',
required: false,
admin: {
description: 'Light mode logo',
condition: (data) => data.company.showCompany,
},
},
],
},
{
name: 'participants',
type: 'group',
fields: [
{
name: 'showParticipants',
type: 'checkbox',
defaultValue: false,
admin: {
description:
'Could be speakers, authors, guests, etc. It would source from Authors collections.',
},
},
{
name: 'participants',
type: 'relationship',
relationTo: 'authors',
hasMany: true,
admin: {
condition: (data) => data.participants.showParticipants,
},
},
],
},
],
},
{
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
},
],
},
},
],
timestamps: true,
hooks: {
afterChange: [revalidateEvent],
// afterRead: [populateAuthors],
afterDelete: [revalidateDelete],
},
versions: {
drafts: {
autosave: {
interval: 100,
},
schedulePublish: true,
},
maxPerDoc: 50,
},
}
export default Events

View File

@@ -0,0 +1,71 @@
import type { CollectionConfig } from 'payload'
import {
FixedToolbarFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
export const Media: CollectionConfig = {
slug: 'media',
access: {
create: isAuthenticated,
delete: isAuthenticated,
read: isAnyone,
update: isAuthenticated,
},
fields: [
{
name: 'alt',
type: 'text',
},
{
name: 'caption',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
},
}),
},
],
upload: {
adminThumbnail: 'thumbnail',
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
width: 300,
},
{
name: 'square',
width: 500,
height: 500,
},
{
name: 'small',
width: 600,
},
{
name: 'medium',
width: 900,
},
{
name: 'large',
width: 1400,
},
{
name: 'xlarge',
width: 1920,
},
{
name: 'og',
width: 1200,
height: 630,
crop: 'center',
},
],
},
}

View File

@@ -0,0 +1,33 @@
import type { CollectionAfterReadHook } from 'payload'
import { Author } from 'src/payload-types'
export const populateAuthors: CollectionAfterReadHook = async ({ doc, req: { payload } }) => {
if (doc?.authors && doc?.authors?.length > 0) {
const authorDocs: Author[] = []
for (const author of doc.authors) {
try {
const authorDoc = await payload.findByID({
id: typeof author === 'object' ? author?.id : author,
collection: 'authors',
depth: 0,
})
if (authorDoc) {
authorDocs.push(authorDoc)
}
if (authorDocs.length > 0) {
doc.populatedAuthors = authorDocs.map((authorDoc) => ({
id: authorDoc.id,
name: authorDoc.author,
}))
}
} catch {
// swallow error
}
}
}
return doc
}

View File

@@ -0,0 +1,44 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { Post } from '../../../payload-types'
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = `/posts/${doc.slug}`
payload.logger.info(`Revalidating post at path: ${path}`)
revalidatePath(path)
revalidateTag('posts-sitemap')
}
// 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)
revalidateTag('posts-sitemap')
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = `/posts/${doc?.slug}`
revalidatePath(path)
revalidateTag('posts-sitemap')
}
return doc
}

View File

@@ -0,0 +1,276 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
import { isAnyone } from '@/access/isAnyone'
import { isAuthenticated } from '@/access/isAuthenticated'
import { Banner } from '@/blocks/Banner/config'
import { Code } from '@/blocks/Code/config'
import { MediaBlock } from '@/blocks/MediaBlock/config'
import { Quote } from '@/blocks/Quote/config'
import { YouTube } from '@/blocks/YouTube/config'
import { populateAuthors } from './hooks/populateAuthors'
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
const launchweekOptions = [
{ label: '6', value: '6' },
{ label: '7', value: '7' },
{ label: '8', value: '8' },
{ label: 'x', value: 'x' },
{ label: 'ga', value: 'ga' },
{ label: '12', value: '12' },
{ label: '13', value: '13' },
{ label: '14', value: '14' },
{ label: '15', value: '15' },
]
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
livePreview: {
url: ({ data }) => {
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
// Always use the preview route for live preview to ensure draft mode is enabled
return `${baseUrl}/api/preview?slug=${data?.slug}&secret=${process.env.PREVIEW_SECRET || 'preview-secret'}`
},
breakpoints: [
{
label: 'Desktop',
name: 'desktop',
width: 1920,
height: 1080,
},
{
label: 'Tablet',
name: 'tablet',
width: 768,
height: 1024,
},
{
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
},
],
},
preview: (data) => {
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
// Always use the preview route to ensure draft mode is enabled
return `${baseUrl}/api/preview?slug=${data?.slug}&secret=${process.env.PREVIEW_SECRET || 'preview-secret'}`
},
},
access: {
create: isAuthenticated,
delete: isAuthenticated,
read: isAnyone,
update: isAuthenticated,
},
defaultPopulate: {
title: true,
slug: true,
categories: true,
meta: {
image: true,
description: true,
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
...slugField(),
{
type: 'tabs',
tabs: [
{
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
]
},
}),
label: false,
required: true,
},
],
label: 'Content',
},
{
fields: [
{
name: 'thumb',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: false,
},
{
name: 'categories',
type: 'relationship',
admin: {
position: 'sidebar',
},
hasMany: true,
relationTo: 'categories',
},
{
name: 'launchweek',
type: 'select',
options: launchweekOptions,
admin: {
description:
'Select a launch week to show launch week summary at the bottom of the blog post.',
},
},
{
name: 'readingTime',
type: 'number',
admin: {
hidden: true,
},
},
{
name: 'date',
type: 'date',
admin: {
position: 'sidebar',
},
},
{
name: 'toc_depth',
type: 'number',
defaultValue: 2,
admin: {
position: 'sidebar',
},
},
{
name: 'description',
type: 'textarea',
},
{
name: 'authors',
type: 'relationship',
relationTo: 'authors',
hasMany: true,
admin: {
position: 'sidebar',
},
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
admin: {
position: 'sidebar',
},
},
],
label: 'Metadata',
},
{
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
},
],
},
},
],
timestamps: true,
hooks: {
afterChange: [revalidatePost],
afterRead: [populateAuthors],
afterDelete: [revalidateDelete],
},
versions: {
drafts: {
autosave: {
interval: 200,
},
schedulePublish: true,
},
maxPerDoc: 50,
},
}
export default Posts

View File

@@ -0,0 +1,21 @@
import { CollectionConfig } from 'payload'
export const Tags: CollectionConfig = {
slug: 'tags',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
],
timestamps: true,
}
export default Tags

View File

@@ -0,0 +1,46 @@
import type { CollectionConfig } from 'payload'
import { isAdmin, isAdminFieldLevel } from '@/access/isAdmin'
import { isAdminOrSelf } from '@/access/isAdminOrSelf'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
access: {
// Only admins can create users
create: isAdmin,
// Admins can read all, but any other logged in user can only read themselves
read: isAdminOrSelf,
// Admins can update all, but any other logged in user can only update themselves
update: isAdminOrSelf,
// Only admins can delete
delete: isAdmin,
},
fields: [
{
name: 'roles',
// Save this field to JWT so we can use from `req.user`
saveToJWT: true,
type: 'select',
hasMany: true,
defaultValue: ['editor'],
access: {
// Only admins can create or update a value for this field
create: isAdminFieldLevel,
update: isAdminFieldLevel,
},
options: [
{
label: 'Admin',
value: 'admin',
},
{
label: 'Editor',
value: 'editor',
},
],
},
],
}

View File

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

View File

@@ -0,0 +1,89 @@
'use client'
import type { PayloadAdminBarProps, PayloadMeUser } from '@payloadcms/admin-bar'
import { cn } from '@/utilities/ui'
import { useSelectedLayoutSegments } from 'next/navigation'
import { PayloadAdminBar } from '@payloadcms/admin-bar'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import './index.scss'
import { getClientSideURL } from '@/utilities/getURL'
const baseClass = 'admin-bar'
const collectionLabels = {
pages: {
plural: 'Pages',
singular: 'Page',
},
posts: {
plural: 'Posts',
singular: 'Post',
},
projects: {
plural: 'Projects',
singular: 'Project',
},
}
const Title: React.FC = () => <span>Dashboard</span>
export const AdminBar: React.FC<{
adminBarProps?: PayloadAdminBarProps
}> = (props) => {
const { adminBarProps } = props || {}
const segments = useSelectedLayoutSegments()
const [show, setShow] = useState(false)
const collection = (
collectionLabels[segments?.[1] as keyof typeof collectionLabels] ? segments[1] : 'pages'
) as keyof typeof collectionLabels
const router = useRouter()
const onAuthChange = React.useCallback((user: PayloadMeUser) => {
setShow(Boolean(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={getClientSideURL()}
collectionSlug={collection}
collectionLabels={{
plural: collectionLabels[collection]?.plural || 'Pages',
singular: collectionLabels[collection]?.singular || 'Page',
}}
logo={<Title />}
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,12 @@
.seedButton {
appearance: none;
background: none;
border: none;
padding: 0;
text-decoration: underline;
&:hover {
cursor: pointer;
opacity: 0.85;
}
}

View File

@@ -0,0 +1,88 @@
'use client'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from '@payloadcms/ui'
import './index.scss'
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 | string>(null)
const handleClick = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (seeded) {
toast.info('Database already seeded.')
return
}
if (loading) {
toast.info('Seeding already in progress.')
return
}
if (error) {
toast.error(`An error occurred, please refresh and try again.`)
return
}
setLoading(true)
try {
toast.promise(
new Promise((resolve, reject) => {
try {
fetch('/next/seed', { method: 'POST', credentials: 'include' })
.then((res) => {
if (res.ok) {
resolve(true)
setSeeded(true)
} else {
reject('An error occurred while seeding.')
}
})
.catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
}),
{
loading: 'Seeding with data....',
success: <SuccessMessage />,
error: 'An error occurred while seeding.',
}
)
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
setError(error)
}
},
[loading, seeded, error]
)
let message = ''
if (loading) message = ' (seeding...)'
if (seeded) message = ' (done!)'
if (error) message = ` (error: ${error})`
return (
<Fragment>
<button className="seedButton" onClick={handleClick}>
Seed your database
</button>
{message}
</Fragment>
)
}

View File

@@ -0,0 +1,24 @@
@import '~@payloadcms/ui/scss';
.dashboard .before-dashboard {
margin-bottom: base(1.5);
&__banner {
& h4 {
margin: 0;
}
}
&__instructions {
list-style: decimal;
margin-bottom: base(0.5);
& li {
width: 100%;
}
}
& a:hover {
opacity: 0.85;
}
}

View File

@@ -0,0 +1,74 @@
import { Banner } from '@payloadcms/ui/elements/Banner'
import React from 'react'
import { SeedButton } from './SeedButton'
import './index.scss'
const baseClass = 'before-dashboard'
const BeforeDashboard: React.FC = () => {
return (
<div className={baseClass}>
<Banner className={`${baseClass}__banner`} type="success">
<h4>Welcome to your dashboard!</h4>
</Banner>
Here&apos;s what to do next:
<ul className={`${baseClass}__instructions`}>
<li>
<SeedButton />
{' with a few pages, posts, and projects to jump-start your new site, then '}
<a href="/" target="_blank">
visit your website
</a>
{' to see the results.'}
</li>
<li>
If you created this repo using Payload Cloud, head over to GitHub and clone it to your
local machine. It will be under the <i>GitHub Scope</i> that you selected when creating
this project.
</li>
<li>
{'Modify your '}
<a
href="https://payloadcms.com/docs/configuration/collections"
rel="noopener noreferrer"
target="_blank"
>
collections
</a>
{' and add more '}
<a
href="https://payloadcms.com/docs/fields/overview"
rel="noopener noreferrer"
target="_blank"
>
fields
</a>
{' as needed. If you are new to Payload, we also recommend you check out the '}
<a
href="https://payloadcms.com/docs/getting-started/what-is-payload"
rel="noopener noreferrer"
target="_blank"
>
Getting Started
</a>
{' docs.'}
</li>
<li>
Commit and push your changes to the repository to trigger a redeployment of your project.
</li>
</ul>
{'Pro Tip: This block is a '}
<a
href="https://payloadcms.com/docs/admin/custom-components/overview#base-component-overrides"
rel="noopener noreferrer"
target="_blank"
>
custom component
</a>
, you can remove it at any time by updating your <strong>payload.config</strong>.
</div>
)
}
export default BeforeDashboard

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,84 @@
'use client'
import { cn } from '@/utilities/ui'
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 type CardPostData = Pick<Post, 'slug' | 'categories' | 'meta' | 'title'>
export const Card: React.FC<{
alignItems?: 'center'
className?: string
doc?: CardPostData
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="33vw" />}
</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,32 @@
import { cn } from '@/utilities/ui'
import React from 'react'
import { Card, CardPostData } from '@/components/Card'
export type Props = {
posts: CardPostData[]
}
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,66 @@
import { Button, type ButtonProps } from '@/components/ui/button'
import { cn } from '@/utilities/ui'
import Link from 'next/link'
import React from 'react'
import type { Page, Post } from '@/payload-types'
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 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={href || url || ''} {...newTabProps}>
{label && label}
{children && children}
</Link>
)
}
return (
<Button asChild className={className} size={size} variant={appearance}>
<Link className={cn(className)} href={href || url || ''} {...newTabProps}>
{label && label}
{children && children}
</Link>
</Button>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
import { getClientSideURL } from '@/utilities/getURL'
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={getClientSideURL()} />
}

View File

@@ -0,0 +1,29 @@
import clsx from 'clsx'
import React from 'react'
interface Props {
className?: string
loading?: 'lazy' | 'eager'
priority?: 'auto' | 'high' | 'low'
}
export const Logo = (props: Props) => {
const { loading: loadingFromProps, priority: priorityFromProps, className } = props
const loading = loadingFromProps || 'lazy'
const priority = priorityFromProps || 'low'
return (
/* eslint-disable @next/next/no-img-element */
<img
alt="Supabase Logo"
width={193}
height={34}
loading={loading}
fetchPriority={priority}
decoding="async"
className={clsx('max-w-[9.375rem] w-full h-[34px]', className)}
src="https://supabase.com/_next/image?url=https%3A%2F%2Ffrontend-assets.supabase.com%2Fwww%2F34022bd5708c%2F_next%2Fstatic%2Fmedia%2Fsupabase-logo-wordmark--dark.b36ebb5f.png&w=256&q=75&dpl=dpl_EbjEEHsJWQ5nbTaMsQgJBoZ7tBCD"
/>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import type { StaticImageData } from 'next/image'
import { cn } from '@/utilities/ui'
import NextImage from 'next/image'
import React from 'react'
import type { Props as MediaProps } from '../types'
import { cssVariables } from '@/cssVariables'
import { getClientSideURL } from '@/utilities/getURL'
const { breakpoints } = cssVariables
// A base64 encoded image to use as a placeholder while the image is loading
const placeholderBlur =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABchJREFUWEdtlwtTG0kMhHtGM7N+AAdcDsjj///EBLzenbtuadbLJaZUTlHB+tRqSesETB3IABqQG1KbUFqDlQorBSmboqeEBcC1d8zrCixXYGZcgMsFmH8B+AngHdurAmXKOE8nHOoBrU6opcGswPi5KSP9CcBaQ9kACJH/ALAA1xm4zMD8AczvQCcAQeJVAZsy7nYApTSUzwCHUKACeUJi9TsFci7AHmDtuHYqQIC9AgQYKnSwNAig4NyOOwXq/xU47gDYggarjIpsRSEA3Fqw7AGkwgW4fgALAdiC2btKgNZwbgdMbEFpqFR2UyCR8xwAhf8bUHIGk1ckMyB5C1YkeWAdAPQBAeiD6wVYPoD1HUgXwFagZAGc6oSpTmilopoD5GzISQD3odcNIFca0BUQQM5YA2DpHV0AYURBDIAL0C+ugC0C4GedSsVUmwC8/4w8TPiwU6AClJ5RWL1PgQNkrABWdKB3YF3cBwRY5lsI4ApkKpCQi+FIgFJU/TDgDuAxAAwonJuKpGD1rkCXCR1ALyrAUSSEQAhwBdYZ6DPAgSUA2c1wKIZmRcHxMzMYR9DH8NlbkAwwApSAcABwBwTAbb6owAr0AFiZPILVEyCtMmK2jCkTwFDNUNj7nJETQx744gCUmgkZVGJUHyakEZE4W91jtGFA9KsD8Z3JFYDlhGYZLWcllwJMnplcPy+csFAgAAaIDOgeuAGoB96GLZg4kmtfMjnr6ig5oSoySsoy3ya/FMivXZWxwr0KIf9nACbfqcBEgmBSAtAlIT83R+70IWpyACamIjf5E1Iqb9ECVmnoI/FvAIRk8s2J0Y5IquQDgB+5wpScw5AUTC75VTmTs+72NUzoCvQIaAXv5Q8PDAZKLD+MxLv3RFE7KlsQChgBIlKiCv5ByaZv3gJZNm8AnVMhAN+EjrtTYQMICJpu6/0aiQnhClANlz+Bw0cIWa8ev0sBrtrhAyaXEnrfGfATQJiRKih5vKeOHNXXPFrgyamAADh0Q4F2/sESojomDS9o9k0b0H83xjB8qL+JNoTjN+enjpaBpingRh4e8MSugudM030A8FeqMI6PFIgNyPehkpZWGFEAARIQdH5LcAAqIACHkAJqg4OoBccHAuz76wr4BbzFOEa8iBuAZB8AtJHLP2VgMgJw/EIBowo7HxCAH3V6dAXEE/vZ5aZIA8BP8RKhm7Cp8BnAMnAQADdgQDA520AVIpScP+enHz0Gwp25h4i2dPg5FkDXrbsdJikQwXuWgaM5gEMk1AgH4DKKFjDf3bMD+FjEeIxLlRKYnBk2BbquvSDCAQ4gwZiMAAmH4gBTyRtEsYxi7gP6QSrc//39BrDNqG8rtYTmC4BV1SfMhOhaumFCT87zy4pPhQBZEK1kQVRjJBBi7AOlePgyAPYjwlvtagx9e/dnQraAyS894TIkkAIEYMKEc8k4EqJ68lZ5jjNqcQC2QteQOf7659umwBgPybNtK4dg9WvnMyFwXYGP7uEO1lwJgAnPNeMYMVXbIIYKFioI4PGFt+BWPVfmWJdjW2lTUnLGCswECAgaUy86iwA1464ajo0QhgMBFGyBoZahANsMpMfXr1JA1SN29m5lqgXj+UPV85uRA7yv/KYUO4Tk7Hc1AZwbIRzg0AyNj2UlAMwfSLSMnl7fdAbcxHuA27YaAMvaQ4GOjwX4RTUGAG8Ge14N963g1AynqUiFqRX9noasxT4b8entNRQYyamk/3tYcHsO7R3XJRRYOn4tw4iUnwBM5gDnySGOreAwAGo8F9IDHEcq8Pz2Kg/oXCpuIL6tOPD8LsDn0ABYQoGFRowlsAEUPPDrGAGowAbgKsgDMmE8mDy/vXQ9IAwI7u4wta+gAdAdgB64Ah9SgD4IgGKhwACoAjgNgFDhtxY8f33ZTMjqdTAiHMBPrn8ZWkEfzFdX4Oc1AHg3+ADbvN8PU8WdFKg4Tt6CQy2+D4YHaMT/JP4XzbAq98cPDIUAAAAASUVORK5CYII='
export const ImageMedia: React.FC<MediaProps> = (props) => {
const {
alt: altFromProps,
fill,
pictureClassName,
imgClassName,
priority,
resource,
size: sizeFromProps,
src: srcFromProps,
loading: loadingFromProps,
} = props
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, height: fullHeight, url, width: fullWidth } = resource
width = fullWidth!
height = fullHeight!
alt = altFromResource || ''
const cacheTag = resource.updatedAt
src = `${getClientSideURL()}${url}?${cacheTag}`
}
const loading = loadingFromProps || (!priority ? 'lazy' : undefined)
// 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 * 2}w`)
.join(', ')
return (
<picture className={cn(pictureClassName)}>
<NextImage
alt={alt || ''}
className={cn(imgClassName)}
fill={fill}
height={!fill ? height : undefined}
placeholder="blur"
blurDataURL={placeholderBlur}
priority={priority}
quality={100}
loading={loading}
sizes={sizes}
src={src}
width={!fill ? width : undefined}
/>
</picture>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { cn } from '@/utilities/ui'
import React, { useEffect, useRef } from 'react'
import type { Props as MediaProps } from '../types'
import { getClientSideURL } from '@/utilities/getURL'
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={`${getClientSideURL()}/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 || Fragment
return (
<Tag
{...(htmlElement !== null
? {
className,
}
: {})}
>
{isVideo ? <VideoMedia {...props} /> : <ImageMedia {...props} />}
</Tag>
)
}

View File

@@ -0,0 +1,22 @@
import type { StaticImageData } from 'next/image'
import type { ElementType, Ref } from 'react'
import type { Media as MediaType } from '@/payload-types'
export interface Props {
alt?: string
className?: string
fill?: boolean // for NextImage only
htmlElement?: ElementType | null
pictureClassName?: string
imgClassName?: string
onClick?: () => void
onLoad?: () => void
loading?: 'lazy' | 'eager' // for NextImage only
priority?: boolean // for NextImage only
ref?: Ref<HTMLImageElement | HTMLVideoElement | null>
resource?: MediaType | string | number | null // for Payload media
size?: string // for NextImage only
src?: StaticImageData // for static media
videoClassName?: string
}

View File

@@ -0,0 +1,57 @@
import React from 'react'
const defaultLabels = {
plural: 'Docs',
singular: 'Doc',
}
const defaultCollectionLabels = {
posts: {
plural: 'Posts',
singular: 'Post',
},
}
export const PageRange: React.FC<{
className?: string
collection?: keyof typeof defaultCollectionLabels
collectionLabels?: {
plural?: string
singular?: string
}
currentPage?: number
limit?: number
totalDocs?: number
}> = (props) => {
const {
className,
collection,
collectionLabels: collectionLabelsFromProps,
currentPage,
limit,
totalDocs,
} = props
let indexStart = (currentPage ? currentPage - 1 : 1) * (limit || 1) + 1
if (totalDocs && indexStart > totalDocs) indexStart = 0
let indexEnd = (currentPage || 1) * (limit || 1)
if (totalDocs && indexEnd > totalDocs) indexEnd = totalDocs
const { plural, singular } =
collectionLabelsFromProps ||
(collection ? defaultCollectionLabels[collection] : undefined) ||
defaultLabels ||
{}
return (
<div className={[className, 'font-semibold'].filter(Boolean).join(' ')}>
{(typeof totalDocs === 'undefined' || totalDocs === 0) && 'Search produced no results.'}
{typeof totalDocs !== 'undefined' &&
totalDocs > 0 &&
`Showing ${indexStart}${indexStart > 0 ? ` - ${indexEnd}` : ''} of ${totalDocs} ${
totalDocs > 1 ? plural : singular
}`}
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import {
Pagination as PaginationComponent,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { cn } from '@/utilities/ui'
import { useRouter } from 'next/navigation'
import React from 'react'
export const Pagination: React.FC<{
className?: string
page: number
totalPages: number
}> = (props) => {
const router = useRouter()
const { className, page, totalPages } = props
const hasNextPage = page < totalPages
const hasPrevPage = page > 1
const hasExtraPrevPages = page - 1 > 1
const hasExtraNextPages = page + 1 < totalPages
return (
<div className={cn('my-12', className)}>
<PaginationComponent>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={!hasPrevPage}
onClick={() => {
router.push(`/posts/page/${page - 1}`)
}}
/>
</PaginationItem>
{hasExtraPrevPages && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{hasPrevPage && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(`/posts/page/${page - 1}`)
}}
>
{page - 1}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
isActive
onClick={() => {
router.push(`/posts/page/${page}`)
}}
>
{page}
</PaginationLink>
</PaginationItem>
{hasNextPage && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(`/posts/page/${page + 1}`)
}}
>
{page + 1}
</PaginationLink>
</PaginationItem>
)}
{hasExtraNextPages && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
disabled={!hasNextPage}
onClick={() => {
router.push(`/posts/page/${page + 1}`)
}}
/>
</PaginationItem>
</PaginationContent>
</PaginationComponent>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import type React from 'react'
import type { Post } from '@/payload-types'
import { getCachedDocument } from '@/utilities/getDocument'
import { getCachedRedirects } from '@/utilities/getRedirects'
import { notFound, redirect } from 'next/navigation'
interface Props {
disableNotFound?: boolean
url: string
}
/* This component helps us with SSR based dynamic redirects */
export const PayloadRedirects: React.FC<Props> = async ({ disableNotFound, url }) => {
const redirects = await getCachedRedirects()()
const redirectItem = redirects.find((redirect) => redirect.from === url)
if (redirectItem) {
if (redirectItem.to?.url) {
redirect(redirectItem.to.url)
}
let redirectUrl: string
if (typeof redirectItem.to?.reference?.value === 'string') {
const collection = redirectItem.to?.reference?.relationTo
const id = redirectItem.to?.reference?.value
const document = (await getCachedDocument(collection, id)()) as Post
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
document?.slug
}`
} else {
redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${
typeof redirectItem.to?.reference?.value === 'object'
? redirectItem.to?.reference?.value?.slug
: ''
}`
}
if (redirectUrl) redirect(redirectUrl)
}
if (disableNotFound) return null
notFound()
}

View File

@@ -0,0 +1,94 @@
import { MediaBlock } from '@/blocks/MediaBlock/Component'
import {
DefaultNodeTypes,
SerializedBlockNode,
SerializedLinkNode,
type DefaultTypedEditorState,
} from '@payloadcms/richtext-lexical'
import {
JSXConvertersFunction,
LinkJSXConverter,
RichText as ConvertRichText,
} from '@payloadcms/richtext-lexical/react'
import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component'
import type {
BannerBlock as BannerBlockProps,
CallToActionBlock as CTABlockProps,
MediaBlock as MediaBlockProps,
QuoteBlock as QuoteBlockProps,
YouTubeBlock as YouTubeBlockProps,
} from '@/payload-types'
import { BannerBlock } from '@/blocks/Banner/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
import { QuoteBlock } from '@/blocks/Quote/Component'
import { YouTubeBlock } from '@/blocks/YouTube/Component'
import { cn } from '@/utilities/ui'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<
| CTABlockProps
| MediaBlockProps
| BannerBlockProps
| CodeBlockProps
| QuoteBlockProps
| YouTubeBlockProps
>
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { value, relationTo } = linkNode.fields.doc!
if (typeof value !== 'object') {
throw new Error('Expected value to be an object')
}
const slug = value.slug
return relationTo === 'posts' ? `/posts/${slug}` : `/${slug}`
}
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
...LinkJSXConverter({ internalDocToHref }),
blocks: {
banner: ({ node }) => <BannerBlock className="col-start-2 mb-4" {...node.fields} />,
mediaBlock: ({ node }) => (
<MediaBlock
className="col-start-1 col-span-3"
imgClassName="m-0"
{...node.fields}
captionClassName="mx-auto max-w-[48rem]"
enableGutter={false}
disableInnerContainer={true}
/>
),
code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,
cta: ({ node }) => <CallToActionBlock {...node.fields} />,
quote: ({ node }) => <QuoteBlock className="col-start-2" {...node.fields} />,
youtube: ({ node }) => <YouTubeBlock className="col-start-2" {...node.fields} />,
},
})
type Props = {
data: DefaultTypedEditorState
enableGutter?: boolean
enableProse?: boolean
} & React.HTMLAttributes<HTMLDivElement>
export default function RichText(props: Props) {
const { className, enableProse = true, enableGutter = true, ...rest } = props
return (
<ConvertRichText
converters={jsxConverters}
className={cn(
'payload-richtext',
{
container: enableGutter,
'max-w-none': !enableGutter,
'mx-auto prose md:prose-md dark:prose-invert': enableProse,
},
className
)}
{...rest}
/>
)
}

View File

@@ -0,0 +1,52 @@
import { cn } from '@/utilities/ui'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
defaultVariants: {
size: 'default',
variant: 'default',
},
variants: {
size: {
clear: '',
default: 'h-10 px-4 py-2',
icon: 'h-10 w-10',
lg: 'h-11 rounded px-8',
sm: 'h-9 rounded px-3',
},
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-card hover:text-accent-foreground',
link: 'text-primary items-start justify-start underline-offset-4 hover:underline',
outline: 'border border-border bg-background hover:bg-card hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
},
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
ref?: React.Ref<HTMLButtonElement>
}
const Button: React.FC<ButtonProps> = ({
asChild = false,
className,
size,
variant,
ref,
...props
}) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ className, size, variant }))} ref={ref} {...props} />
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,48 @@
import { cn } from '@/utilities/ui'
import * as React from 'react'
const Card: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
ref={ref}
{...props}
/>
)
const CardHeader: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('flex flex-col space-y-1.5 p-6', className)} ref={ref} {...props} />
)
const CardTitle: React.FC<
{ ref?: React.Ref<HTMLHeadingElement> } & React.HTMLAttributes<HTMLHeadingElement>
> = ({ className, ref, ...props }) => (
<h3
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
ref={ref}
{...props}
/>
)
const CardDescription: React.FC<
{ ref?: React.Ref<HTMLParagraphElement> } & React.HTMLAttributes<HTMLParagraphElement>
> = ({ className, ref, ...props }) => (
<p className={cn('text-sm text-muted-foreground', className)} ref={ref} {...props} />
)
const CardContent: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('p-6 pt-0', className)} ref={ref} {...props} />
)
const CardFooter: React.FC<
{ ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>
> = ({ className, ref, ...props }) => (
<div className={cn('flex items-center p-6 pt-0', className)} ref={ref} {...props} />
)
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

View File

@@ -0,0 +1,27 @@
'use client'
import { cn } from '@/utilities/ui'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import * as React from 'react'
const Checkbox: React.FC<
{
ref?: React.Ref<HTMLButtonElement>
} & React.ComponentProps<typeof CheckboxPrimitive.Root>
> = ({ className, ref, ...props }) => (
<CheckboxPrimitive.Root
className={cn(
'peer h-4 w-4 shrink-0 rounded border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
ref={ref}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
export { Checkbox }

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utilities/ui'
import * as React from 'react'
const Input: React.FC<
{
ref?: React.Ref<HTMLInputElement>
} & React.InputHTMLAttributes<HTMLInputElement>
> = ({ type, className, ref, ...props }) => {
return (
<input
className={cn(
'flex h-10 w-full rounded border border-border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
type={type}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,19 @@
'use client'
import { cn } from '@/utilities/ui'
import * as LabelPrimitive from '@radix-ui/react-label'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label: React.FC<
{ ref?: React.Ref<HTMLLabelElement> } & React.ComponentProps<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
> = ({ className, ref, ...props }) => (
<LabelPrimitive.Root className={cn(labelVariants(), className)} ref={ref} {...props} />
)
export { Label }

View File

@@ -0,0 +1,92 @@
import type { ButtonProps } from '@/components/ui/button'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/utilities/ui'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import * as React from 'react'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
role="navigation"
{...props}
/>
)
const PaginationContent: React.FC<
{ ref?: React.Ref<HTMLUListElement> } & React.HTMLAttributes<HTMLUListElement>
> = ({ className, ref, ...props }) => (
<ul className={cn('flex flex-row items-center gap-1', className)} ref={ref} {...props} />
)
const PaginationItem: React.FC<
{ ref?: React.Ref<HTMLLIElement> } & React.HTMLAttributes<HTMLLIElement>
> = ({ className, ref, ...props }) => <li className={cn('', className)} ref={ref} {...props} />
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'button'>
const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
<button
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
size,
variant: isActive ? 'outline' : 'ghost',
}),
className
)}
{...props}
/>
)
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
className={cn('gap-1 pl-2.5', className)}
size="default"
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
className={cn('gap-1 pr-2.5', className)}
size="default"
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

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